diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index d24c55b49ac2..1a76a7f47160 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -54,9 +54,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-subtests + pip install pytest pytest-subtests pytest-xdist python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python Launcher.py --update_settings # make sure host.yaml exists for tests - name: Unittests run: | - pytest + pytest -n auto diff --git a/.gitignore b/.gitignore index 8e4cc86657a5..f4bcd35c32ae 100644 --- a/.gitignore +++ b/.gitignore @@ -27,16 +27,20 @@ *.archipelago *.apsave *.BIN +*.puml setups build bundle/components.wxs dist +/prof/ README.html .vs/ EnemizerCLI/ /Players/ /SNI/ +/sni-*/ +/appimagetool* /host.yaml /options.yaml /config.yaml @@ -139,6 +143,7 @@ ipython_config.py .venv* env/ venv/ +/venv*/ ENV/ env.bak/ venv.bak/ diff --git a/BaseClasses.py b/BaseClasses.py index 26cdfb528569..1b6677dd1942 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -8,6 +8,7 @@ import typing # this can go away when Python 3.8 support is dropped from argparse import Namespace from collections import ChainMap, Counter, deque +from collections.abc import Collection from enum import IntEnum, IntFlag from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ Type, ClassVar @@ -202,14 +203,7 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu self.player_types[new_id] = NetUtils.SlotType.group self._region_cache[new_id] = {} world_type = AutoWorld.AutoWorldRegister.world_types[game] - for option_key, option in world_type.option_definitions.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.per_game_common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - - self.worlds[new_id] = world_type(self, new_id) + self.worlds[new_id] = world_type.create_group(self, new_id, players) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) self.player_name[new_id] = name @@ -232,25 +226,24 @@ def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optio range(1, self.players + 1)} def set_options(self, args: Namespace) -> None: - for option_key in Options.common_options: - setattr(self, option_key, getattr(args, option_key, {})) - for option_key in Options.per_game_common_options: - setattr(self, option_key, getattr(args, option_key, {})) - for player in self.player_ids: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] - for option_key in world_type.option_definitions: - setattr(self, option_key, getattr(args, option_key, {})) - self.worlds[player] = world_type(self, player) self.worlds[player].random = self.per_slot_randoms[player] + for option_key in world_type.options_dataclass.type_hints: + option_values = getattr(args, option_key, {}) + setattr(self, option_key, option_values) + # TODO - remove this loop once all worlds use options dataclasses + options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass + self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] + for option_key in options_dataclass.type_hints}) def set_item_links(self): item_links = {} replacement_prio = [False, True, None] for player in self.player_ids: - for item_link in self.item_links[player].value: + for item_link in self.worlds[player].options.item_links.value: if item_link["name"] in item_links: if item_links[item_link["name"]]["game"] != self.game[player]: raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}") @@ -305,14 +298,6 @@ def set_item_links(self): group["non_local_items"] = item_link["non_local_items"] group["link_replacement"] = replacement_prio[item_link["link_replacement"]] - # intended for unittests - def set_default_common_options(self): - for option_key, option in Options.common_options.items(): - setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids}) - for option_key, option in Options.per_game_common_options.items(): - setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids}) - self.state = CollectionState(self) - def secure(self): self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.is_race = True @@ -364,7 +349,7 @@ def _recache(self): for r_location in region.locations: self._location_cache[r_location.name, player] = r_location - def get_regions(self, player=None): + def get_regions(self, player: Optional[int] = None) -> Collection[Region]: return self.regions if player is None else self._region_cache[player].values() def get_region(self, regionname: str, player: int) -> Region: @@ -869,19 +854,19 @@ def add_locations(self, locations: Dict[str, Optional[int]], """ Adds locations to the Region object, where location_type is your Location class and locations is a dict of location names to address. - + :param locations: dictionary of locations to be created and added to this Region `{name: ID}` :param location_type: Location class to be used to create the locations with""" if location_type is None: location_type = Location for location, address in locations.items(): self.locations.append(location_type(self.player, location, address, self)) - + def connect(self, connecting_region: Region, name: Optional[str] = None, rule: Optional[Callable[[CollectionState], bool]] = None) -> None: """ Connects this Region to another Region, placing the provided rule on the connection. - + :param connecting_region: Region object to connect to path is `self -> exiting_region` :param name: name of the connection being created :param rule: callable to determine access of this connection to go from self to the exiting_region""" @@ -889,11 +874,11 @@ def connect(self, connecting_region: Region, name: Optional[str] = None, if rule: exit_.access_rule = rule exit_.connect(connecting_region) - + def create_exit(self, name: str) -> Entrance: """ Creates and returns an Entrance object as an exit of this region. - + :param name: name of the Entrance being created """ exit_ = self.entrance_type(self.player, name, self) @@ -1263,7 +1248,7 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st def to_file(self, filename: str) -> None: def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: - res = getattr(self.multiworld, option_key)[player] + res = getattr(self.multiworld.worlds[player].options, option_key) display_name = getattr(option_obj, "display_name", option_key) outfile.write(f"{display_name + ':':33}{res.current_option_name}\n") @@ -1281,8 +1266,7 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions) - for f_option, option in options.items(): + for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): write_option(f_option, option) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) diff --git a/BizHawkClient.py b/BizHawkClient.py new file mode 100644 index 000000000000..86c8e5197e3f --- /dev/null +++ b/BizHawkClient.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import ModuleUpdate +ModuleUpdate.update() + +from worlds._bizhawk.context import launch + +if __name__ == "__main__": + launch() diff --git a/CommonClient.py b/CommonClient.py index 61fad6589793..a5e9b4553ab4 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -1,4 +1,6 @@ from __future__ import annotations + +import copy import logging import asyncio import urllib.parse @@ -242,6 +244,7 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option self.watcher_event = asyncio.Event() self.jsontotextparser = JSONtoTextParser(self) + self.rawjsontotextparser = RawJSONtoTextParser(self) self.update_data_package(network_data_package) # execution @@ -377,10 +380,13 @@ def on_print(self, args: dict): def on_print_json(self, args: dict): if self.ui: - self.ui.print_json(args["data"]) - else: - text = self.jsontotextparser(args["data"]) - logger.info(text) + # send copy to UI + self.ui.print_json(copy.deepcopy(args["data"])) + + logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])), + extra={"NoStream": True}) + logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])), + extra={"NoFile": True}) def on_package(self, cmd: str, args: dict): """For custom package handling in subclasses.""" @@ -876,7 +882,7 @@ def get_base_parser(description: typing.Optional[str] = None): def run_as_textclient(): class TextContext(CommonContext): # Text Mode to use !hint and such with games that have no text entry - tags = {"AP", "TextOnly"} + tags = CommonContext.tags | {"TextOnly"} game = "" # empty matches any game since 0.3.2 items_handling = 0b111 # receive all items for /received want_slot_data = False # Can't use game specific slot_data diff --git a/Fill.py b/Fill.py index 3e0342f42cd3..9d5dc0b45776 100644 --- a/Fill.py +++ b/Fill.py @@ -5,6 +5,8 @@ from collections import Counter, deque from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld +from Options import Accessibility + from worlds.AutoWorld import call_all from worlds.generic.Rules import add_item_rule @@ -70,7 +72,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.accessibility[item_to_place.player] == 'minimal': + if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game @@ -265,7 +267,7 @@ def fast_fill(world: MultiWorld, def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"} + minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: @@ -288,7 +290,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal') + return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) @@ -531,9 +533,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None: # If other players are below the threshold value, swap progression in this sphere into earlier spheres, # which gives more locations available by this sphere. balanceable_players: typing.Dict[int, float] = { - player: world.progression_balancing[player] / 100 + player: world.worlds[player].options.progression_balancing / 100 for player in world.player_ids - if world.progression_balancing[player] > 0 + if world.worlds[player].options.progression_balancing > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') @@ -753,8 +755,6 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: # not reachable with swept state non_early_locations[loc.player].append(loc.name) - # TODO: remove. Preferably by implementing key drop - from worlds.alttp.Regions import key_drop_data world_name_lookup = world.world_name_lookup block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] @@ -840,14 +840,14 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: if "early_locations" in locations: locations.remove("early_locations") - for player in worlds: - locations += early_locations[player] + for target_player in worlds: + locations += early_locations[target_player] if "non_early_locations" in locations: locations.remove("non_early_locations") - for player in worlds: - locations += non_early_locations[player] + for target_player in worlds: + locations += non_early_locations[target_player] - block['locations'] = locations + block['locations'] = list(dict.fromkeys(locations)) if not block['count']: block['count'] = (min(len(block['items']), len(block['locations'])) if @@ -897,23 +897,22 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: for item_name in items: item = world.worlds[player].create_item(item_name) for location in reversed(candidates): - if location in key_drop_data: - warn( - f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.") - continue - if not location.item: - if location.item_rule(item): - if location.can_fill(world.state, item, False): - successful_pairs.append((item, location)) - candidates.remove(location) - count = count + 1 - break + if (location.address is None) == (item.code is None): # either both None or both not None + if not location.item: + if location.item_rule(item): + if location.can_fill(world.state, item, False): + successful_pairs.append((item, location)) + candidates.remove(location) + count = count + 1 + break + else: + err.append(f"Can't place item at {location} due to fill condition not met.") else: - err.append(f"Can't place item at {location} due to fill condition not met.") + err.append(f"{item_name} not allowed at {location}.") else: - err.append(f"{item_name} not allowed at {location}.") + err.append(f"Cannot place {item_name} into already filled location {location}.") else: - err.append(f"Cannot place {item_name} into already filled location {location}.") + err.append(f"Mismatch between {item_name} and {location}, only one is an event.") if count == maxcount: break if count < placement['count']['min']: diff --git a/Generate.py b/Generate.py index 5d44a1db4550..08fe2b908335 100644 --- a/Generate.py +++ b/Generate.py @@ -157,7 +157,8 @@ def main(args=None, callback=ERmain): for yaml in weights_cache[path]: if category_name is None: for category in yaml: - if category in AutoWorldRegister.world_types and key in Options.common_options: + if category in AutoWorldRegister.world_types and \ + key in Options.CommonOptions.type_hints: yaml[category][key] = option elif category_name not in yaml: logging.warning(f"Meta: Category {category_name} is not present in {path}.") @@ -340,7 +341,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: return get_choice(option_key, category_dict) if game in AutoWorldRegister.world_types: game_world = AutoWorldRegister.world_types[game] - options = ChainMap(game_world.option_definitions, Options.per_game_common_options) + options = game_world.options_dataclass.type_hints if option_key in options: if options[option_key].supports_weighting: return get_choice(option_key, category_dict) @@ -445,8 +446,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b f"which is not enabled.") ret = argparse.Namespace() - for option_key in Options.per_game_common_options: - if option_key in weights and option_key not in Options.common_options: + for option_key in Options.PerGameCommonOptions.type_hints: + if option_key in weights and option_key not in Options.CommonOptions.type_hints: raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") ret.game = get_choice("game", weights) @@ -466,16 +467,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b game_weights = weights[ret.game] ret.name = get_choice('name', weights) - for option_key, option in Options.common_options.items(): + for option_key, option in Options.CommonOptions.type_hints.items(): setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) - for option_key, option in world_type.option_definitions.items(): + for option_key, option in world_type.options_dataclass.type_hints.items(): handle_option(ret, game_weights, option_key, option, plando_options) - for option_key, option in Options.per_game_common_options.items(): - # skip setting this option if already set from common_options, defaulting to root option - if option_key not in world_type.option_definitions and \ - (option_key not in Options.common_options or option_key in game_weights): - handle_option(ret, game_weights, option_key, option, plando_options) if PlandoOptions.items in plando_options: ret.plando_items = game_weights.get("plando_items", []) if ret.game == "Minecraft" or ret.game == "Ocarina of Time": diff --git a/Launcher.py b/Launcher.py index a1548d594ce8..9e184bf1088d 100644 --- a/Launcher.py +++ b/Launcher.py @@ -50,17 +50,22 @@ def open_host_yaml(): def open_patch(): suffixes = [] for c in components: - if isfile(get_exe(c)[-1]): - suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \ - isinstance(c.file_identifier, SuffixIdentifier) else [] + if c.type == Type.CLIENT and \ + isinstance(c.file_identifier, SuffixIdentifier) and \ + (c.script_name is None or isfile(get_exe(c)[-1])): + suffixes += c.file_identifier.suffixes try: - filename = open_filename('Select patch', (('Patches', suffixes),)) + filename = open_filename("Select patch", (("Patches", suffixes),)) except Exception as e: - messagebox('Error', str(e), error=True) + messagebox("Error", str(e), error=True) else: file, component = identify(filename) if file and component: - launch([*get_exe(component), file], component.cli) + exe = get_exe(component) + if exe is None or not isfile(exe[-1]): + exe = get_exe("Launcher") + + launch([*exe, file], component.cli) def generate_yamls(): @@ -107,7 +112,7 @@ def identify(path: Union[None, str]): return None, None for component in components: if component.handles_file(path): - return path, component + return path, component elif path == component.display_name or path == component.script_name: return None, component return None, None @@ -117,25 +122,25 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]: if isinstance(component, str): name = component component = None - if name.startswith('Archipelago'): + if name.startswith("Archipelago"): name = name[11:] - if name.endswith('.exe'): + if name.endswith(".exe"): name = name[:-4] - if name.endswith('.py'): + if name.endswith(".py"): name = name[:-3] if not name: return None for c in components: - if c.script_name == name or c.frozen_name == f'Archipelago{name}': + if c.script_name == name or c.frozen_name == f"Archipelago{name}": component = c break if not component: return None if is_frozen(): - suffix = '.exe' if is_windows else '' - return [local_path(f'{component.frozen_name}{suffix}')] + suffix = ".exe" if is_windows else "" + return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None else: - return [sys.executable, local_path(f'{component.script_name}.py')] + return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None def launch(exe, in_terminal=False): diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 802ec47dd1f0..9c5bd102440b 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -1004,6 +1004,7 @@ def update_sprites(event): self.add_to_sprite_pool(sprite) def icon_section(self, frame_label, path, no_results_label): + os.makedirs(path, exist_ok=True) frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5) frame.pack(side=TOP, fill=X) diff --git a/Main.py b/Main.py index fe56dc7d9e09..0995d2091f7b 100644 --- a/Main.py +++ b/Main.py @@ -108,7 +108,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info('') for player in world.player_ids: - for item_name, count in world.start_inventory[player].value.items(): + for item_name, count in world.worlds[player].options.start_inventory.value.items(): for _ in range(count): world.push_precollected(world.create_item(item_name, player)) @@ -130,23 +130,29 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player in world.player_ids: # items can't be both local and non-local, prefer local - world.non_local_items[player].value -= world.local_items[player].value - world.non_local_items[player].value -= set(world.local_early_items[player]) + world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value + world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) AutoWorld.call_all(world, "set_rules") for player in world.player_ids: - exclusion_rules(world, player, world.exclude_locations[player].value) - world.priority_locations[player].value -= world.exclude_locations[player].value - for location_name in world.priority_locations[player].value: - world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY + exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) + world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value + for location_name in world.worlds[player].options.priority_locations.value: + try: + location = world.get_location(location_name, player) + except KeyError as e: # failed to find the given location. Check if it's a legitimate location + if location_name not in world.worlds[player].location_name_to_id: + raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e + else: + location.progress_type = LocationProgressType.PRIORITY # Set local and non-local item rules. if world.players > 1: locality_rules(world) else: - world.non_local_items[1].value = set() - world.local_items[1].value = set() + world.worlds[1].options.non_local_items.value = set() + world.worlds[1].options.local_items.value = set() AutoWorld.call_all(world, "generate_basic") @@ -159,7 +165,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player, items in depletion_pool.items(): player_world: AutoWorld.World = world.worlds[player] for count in items.values(): - new_items.append(player_world.create_filler()) + for _ in range(count): + new_items.append(player_world.create_filler()) target: int = sum(sum(items.values()) for items in depletion_pool.values()) for i, item in enumerate(world.itempool): if depletion_pool[item.player].get(item.name, 0): @@ -179,6 +186,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No if remaining_items: raise Exception(f"{world.get_player_name(player)}" f" is trying to remove items from their pool that don't exist: {remaining_items}") + assert len(world.itempool) == len(new_items), "Item Pool amounts should not change." world.itempool[:] = new_items # temporary home for item links, should be moved out of Main @@ -293,15 +301,16 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ output = tempfile.TemporaryDirectory() with output as temp_dir: - with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool: + output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__ + is not world.worlds[player].generate_output.__code__] + with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool: check_accessibility_task = pool.submit(world.fulfills_accessibility) output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] - for player in world.player_ids: + for player in output_players: # skip starting a thread for methods that say "pass". - if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__: - output_file_futures.append( - pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) + output_file_futures.append( + pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) # collect ER hint info er_hint_data: Dict[int, Dict[int, str]] = {} @@ -352,11 +361,11 @@ def precollect_hint(location): f" {location}" locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags - if location.name in world.start_location_hints[location.player]: + if location.name in world.worlds[location.player].options.start_location_hints: precollect_hint(location) - elif location.item.name in world.start_hints[location.item.player]: + elif location.item.name in world.worlds[location.item.player].options.start_hints: precollect_hint(location) - elif any([location.item.name in world.start_hints[player] + elif any([location.item.name in world.worlds[player].options.start_hints for player in world.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) @@ -392,7 +401,7 @@ def precollect_hint(location): f.write(bytes([3])) # version of format f.write(multidata) - multidata_task = pool.submit(write_multidata) + output_file_futures.append(pool.submit(write_multidata)) if not check_accessibility_task.result(): if not world.can_beat_game(): raise Exception("Game appears as unbeatable. Aborting.") @@ -400,7 +409,6 @@ def precollect_hint(location): logger.warning("Location Accessibility requirements not fulfilled.") # retrieve exceptions via .result() if they occurred. - multidata_task.result() for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1): if i % 10 == 0 or i == len(output_file_futures): logger.info(f'Generating output files ({i}/{len(output_file_futures)}).') diff --git a/NetUtils.py b/NetUtils.py index c31aa695104c..a2db6a2ac5c4 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -407,14 +407,22 @@ def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[in if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub LocationStore = _LocationStore else: - try: - import pyximport - pyximport.install() - except ImportError: - pyximport = None try: from _speedups import LocationStore + import _speedups + import os.path + if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"): + warnings.warn(f"{_speedups.__file__} outdated! " + f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!") except ImportError: - warnings.warn("_speedups not available. Falling back to pure python LocationStore. " - "Install a matching C++ compiler for your platform to compile _speedups.") - LocationStore = _LocationStore + try: + import pyximport + pyximport.install() + except ImportError: + pyximport = None + try: + from _speedups import LocationStore + except ImportError: + warnings.warn("_speedups not available. Falling back to pure python LocationStore. " + "Install a matching C++ compiler for your platform to compile _speedups.") + LocationStore = _LocationStore diff --git a/Options.py b/Options.py index 960e6c19d1ad..d9ddfc2e2fdb 100644 --- a/Options.py +++ b/Options.py @@ -2,6 +2,9 @@ import abc import logging +from copy import deepcopy +from dataclasses import dataclass +import functools import math import numbers import random @@ -211,6 +214,12 @@ def __gt__(self, other: typing.Union[int, NumericOption]) -> bool: else: return self.value > other + def __ge__(self, other: typing.Union[int, NumericOption]) -> bool: + if isinstance(other, NumericOption): + return self.value >= other.value + else: + return self.value >= other + def __bool__(self) -> bool: return bool(self.value) @@ -896,10 +905,55 @@ class ProgressionBalancing(SpecialRange): } -common_options = { - "progression_balancing": ProgressionBalancing, - "accessibility": Accessibility -} +class OptionsMetaProperty(type): + def __new__(mcs, + name: str, + bases: typing.Tuple[type, ...], + attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty": + for attr_type in attrs.values(): + assert not isinstance(attr_type, AssembleOptions),\ + f"Options for {name} should be type hinted on the class, not assigned" + return super().__new__(mcs, name, bases, attrs) + + @property + @functools.lru_cache(maxsize=None) + def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]: + """Returns type hints of the class as a dictionary.""" + return typing.get_type_hints(cls) + + +@dataclass +class CommonOptions(metaclass=OptionsMetaProperty): + progression_balancing: ProgressionBalancing + accessibility: Accessibility + + def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]: + """ + Returns a dictionary of [str, Option.value] + + :param option_names: names of the options to return + :param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` + """ + option_results = {} + for option_name in option_names: + if option_name in type(self).type_hints: + if casing == "snake": + display_name = option_name + elif casing == "camel": + split_name = [name.title() for name in option_name.split("_")] + split_name[0] = split_name[0].lower() + display_name = "".join(split_name) + elif casing == "pascal": + display_name = "".join([name.title() for name in option_name.split("_")]) + elif casing == "kebab": + display_name = option_name.replace("_", "-") + else: + raise ValueError(f"{casing} is invalid casing for as_dict. " + "Valid names are 'snake', 'camel', 'pascal', 'kebab'.") + option_results[display_name] = getattr(self, option_name).value + else: + raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") + return option_results class LocalItems(ItemSet): @@ -1020,17 +1074,16 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P link.setdefault("link_replacement", None) -per_game_common_options = { - **common_options, # can be overwritten per-game - "local_items": LocalItems, - "non_local_items": NonLocalItems, - "start_inventory": StartInventory, - "start_hints": StartHints, - "start_location_hints": StartLocationHints, - "exclude_locations": ExcludeLocations, - "priority_locations": PriorityLocations, - "item_links": ItemLinks -} +@dataclass +class PerGameCommonOptions(CommonOptions): + local_items: LocalItems + non_local_items: NonLocalItems + start_inventory: StartInventory + start_hints: StartHints + start_location_hints: StartLocationHints + exclude_locations: ExcludeLocations + priority_locations: PriorityLocations + item_links: ItemLinks def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): @@ -1071,10 +1124,7 @@ def dictify_range(option: typing.Union[Range, SpecialRange]): for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - all_options: typing.Dict[str, AssembleOptions] = { - **per_game_common_options, - **world.option_definitions - } + all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints with open(local_path("data", "options.yaml")) as f: file_data = f.read() diff --git a/SNIClient.py b/SNIClient.py index 66d0b2ca9c22..0909c61382b6 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -68,12 +68,11 @@ def connect_to_snes(self, snes_options: str = "") -> bool: options = snes_options.split() num_options = len(options) - if num_options > 0: - snes_device_number = int(options[0]) - if num_options > 1: snes_address = options[0] snes_device_number = int(options[1]) + elif num_options > 0: + snes_device_number = int(options[0]) self.ctx.snes_reconnect_address = None if self.ctx.snes_connect_task: diff --git a/Starcraft2Client.py b/Starcraft2Client.py index cdcdb39a0b44..87b50d35063e 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -1,1049 +1,11 @@ from __future__ import annotations -import asyncio -import copy -import ctypes -import logging -import multiprocessing -import os.path -import re -import sys -import typing -import queue -import zipfile -import io -from pathlib import Path +import ModuleUpdate +ModuleUpdate.update() -# CommonClient import first to trigger ModuleUpdater -from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser -from Utils import init_logging, is_windows +from worlds.sc2wol.Client import launch +import Utils if __name__ == "__main__": - init_logging("SC2Client", exception_logger="Client") - -logger = logging.getLogger("Client") -sc2_logger = logging.getLogger("Starcraft2") - -import nest_asyncio -from worlds._sc2common import bot -from worlds._sc2common.bot.data import Race -from worlds._sc2common.bot.main import run_game -from worlds._sc2common.bot.player import Bot -from worlds.sc2wol import SC2WoLWorld -from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups -from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET -from worlds.sc2wol.MissionTables import lookup_id_to_mission -from worlds.sc2wol.Regions import MissionInfo - -import colorama -from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser -from MultiServer import mark_raw - -nest_asyncio.apply() -max_bonus: int = 8 -victory_modulo: int = 100 - - -class StarcraftClientProcessor(ClientCommandProcessor): - ctx: SC2Context - - def _cmd_difficulty(self, difficulty: str = "") -> bool: - """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" - options = difficulty.split() - num_options = len(options) - - if num_options > 0: - difficulty_choice = options[0].lower() - if difficulty_choice == "casual": - self.ctx.difficulty_override = 0 - elif difficulty_choice == "normal": - self.ctx.difficulty_override = 1 - elif difficulty_choice == "hard": - self.ctx.difficulty_override = 2 - elif difficulty_choice == "brutal": - self.ctx.difficulty_override = 3 - else: - self.output("Unable to parse difficulty '" + options[0] + "'") - return False - - self.output("Difficulty set to " + options[0]) - return True - - else: - if self.ctx.difficulty == -1: - self.output("Please connect to a seed before checking difficulty.") - else: - self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty]) - self.output("To change the difficulty, add the name of the difficulty after the command.") - return False - - def _cmd_disable_mission_check(self) -> bool: - """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play - the next mission in a chain the other player is doing.""" - self.ctx.missions_unlocked = True - sc2_logger.info("Mission check has been disabled") - return True - - def _cmd_play(self, mission_id: str = "") -> bool: - """Start a Starcraft 2 mission""" - - options = mission_id.split() - num_options = len(options) - - if num_options > 0: - mission_number = int(options[0]) - - self.ctx.play_mission(mission_number) - - else: - sc2_logger.info( - "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.") - return False - - return True - - def _cmd_available(self) -> bool: - """Get what missions are currently available to play""" - - request_available_missions(self.ctx) - return True - - def _cmd_unfinished(self) -> bool: - """Get what missions are currently available to play and have not had all locations checked""" - - request_unfinished_missions(self.ctx) - return True - - @mark_raw - def _cmd_set_path(self, path: str = '') -> bool: - """Manually set the SC2 install directory (if the automatic detection fails).""" - if path: - os.environ["SC2PATH"] = path - is_mod_installed_correctly() - return True - else: - sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.") - return False - - def _cmd_download_data(self) -> bool: - """Download the most recent release of the necessary files for playing SC2 with - Archipelago. Will overwrite existing files.""" - if "SC2PATH" not in os.environ: - check_game_install_path() - - if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"): - with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f: - current_ver = f.read() - else: - current_ver = None - - tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', - current_version=current_ver, force_download=True) - - if tempzip != '': - try: - zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"]) - sc2_logger.info(f"Download complete. Version {version} installed.") - with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f: - f.write(version) - finally: - os.remove(tempzip) - else: - sc2_logger.warning("Download aborted/failed. Read the log for more information.") - return False - return True - - -class SC2Context(CommonContext): - command_processor = StarcraftClientProcessor - game = "Starcraft 2 Wings of Liberty" - items_handling = 0b111 - difficulty = -1 - all_in_choice = 0 - mission_order = 0 - mission_req_table: typing.Dict[str, MissionInfo] = {} - final_mission: int = 29 - announcements = queue.Queue() - sc2_run_task: typing.Optional[asyncio.Task] = None - missions_unlocked: bool = False # allow launching missions ignoring requirements - current_tooltip = None - last_loc_list = None - difficulty_override = -1 - mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {} - last_bot: typing.Optional[ArchipelagoBot] = None - - def __init__(self, *args, **kwargs): - super(SC2Context, self).__init__(*args, **kwargs) - self.raw_text_parser = RawJSONtoTextParser(self) - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(SC2Context, self).server_auth(password_requested) - await self.get_username() - await self.send_connect() - - def on_package(self, cmd: str, args: dict): - if cmd in {"Connected"}: - self.difficulty = args["slot_data"]["game_difficulty"] - self.all_in_choice = args["slot_data"]["all_in_map"] - slot_req_table = args["slot_data"]["mission_req"] - # Maintaining backwards compatibility with older slot data - self.mission_req_table = { - mission: MissionInfo( - **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} - ) - for mission, mission_info in slot_req_table.items() - } - self.mission_order = args["slot_data"].get("mission_order", 0) - self.final_mission = args["slot_data"].get("final_mission", 29) - - self.build_location_to_mission_mapping() - - # Looks for the required maps and mods for SC2. Runs check_game_install_path. - maps_present = is_mod_installed_correctly() - if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"): - with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f: - current_ver = f.read() - if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver): - sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.") - elif maps_present: - sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). " - "Run /download_data to update them.") - - - def on_print_json(self, args: dict): - # goes to this world - if "receiving" in args and self.slot_concerns_self(args["receiving"]): - relevant = True - # found in this world - elif "item" in args and self.slot_concerns_self(args["item"].player): - relevant = True - # not related - else: - relevant = False - - if relevant: - self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"]))) - - super(SC2Context, self).on_print_json(args) - - def run_gui(self): - from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.app import App - from kivy.clock import Clock - from kivy.uix.tabbedpanel import TabbedPanelItem - from kivy.uix.gridlayout import GridLayout - from kivy.lang import Builder - from kivy.uix.label import Label - from kivy.uix.button import Button - from kivy.uix.floatlayout import FloatLayout - from kivy.properties import StringProperty - - class HoverableButton(HoverBehavior, Button): - pass - - class MissionButton(HoverableButton): - tooltip_text = StringProperty("Test") - ctx: SC2Context - - def __init__(self, *args, **kwargs): - super(HoverableButton, self).__init__(*args, **kwargs) - self.layout = FloatLayout() - self.popuplabel = ServerToolTip(text=self.text) - self.layout.add_widget(self.popuplabel) - - def on_enter(self): - self.popuplabel.text = self.tooltip_text - - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - if self.tooltip_text == "": - self.ctx.current_tooltip = None - else: - App.get_running_app().root.add_widget(self.layout) - self.ctx.current_tooltip = self.layout - - def on_leave(self): - self.ctx.ui.clear_tooltip() - - @property - def ctx(self) -> CommonContext: - return App.get_running_app().ctx - - class MissionLayout(GridLayout): - pass - - class MissionCategory(GridLayout): - pass - - class SC2Manager(GameManager): - logging_pairs = [ - ("Client", "Archipelago"), - ("Starcraft2", "Starcraft2"), - ] - base_title = "Archipelago Starcraft 2 Client" - - mission_panel = None - last_checked_locations = {} - mission_id_to_button = {} - launching: typing.Union[bool, int] = False # if int -> mission ID - refresh_from_launching = True - first_check = True - ctx: SC2Context - - def __init__(self, ctx): - super().__init__(ctx) - - def clear_tooltip(self): - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - self.ctx.current_tooltip = None - - def build(self): - container = super().build() - - panel = TabbedPanelItem(text="Starcraft 2 Launcher") - self.mission_panel = panel.content = MissionLayout() - - self.tabs.add_widget(panel) - - Clock.schedule_interval(self.build_mission_table, 0.5) - - return container - - def build_mission_table(self, dt): - if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or - not self.refresh_from_launching)) or self.first_check: - self.refresh_from_launching = True - - self.mission_panel.clear_widgets() - if self.ctx.mission_req_table: - self.last_checked_locations = self.ctx.checked_locations.copy() - self.first_check = False - - self.mission_id_to_button = {} - categories = {} - available_missions, unfinished_missions = calc_unfinished_missions(self.ctx) - - # separate missions into categories - for mission in self.ctx.mission_req_table: - if not self.ctx.mission_req_table[mission].category in categories: - categories[self.ctx.mission_req_table[mission].category] = [] - - categories[self.ctx.mission_req_table[mission].category].append(mission) - - for category in categories: - category_panel = MissionCategory() - if category.startswith('_'): - category_display_name = '' - else: - category_display_name = category - category_panel.add_widget( - Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) - - for mission in categories[category]: - text: str = mission - tooltip: str = "" - mission_id: int = self.ctx.mission_req_table[mission].id - # Map has uncollected locations - if mission in unfinished_missions: - text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: - text = f"[color=FFFFFF]{text}[/color]" - # Map requirements not met - else: - text = f"[color=a9a9a9]{text}[/color]" - tooltip = f"Requires: " - if self.ctx.mission_req_table[mission].required_world: - tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for - req_mission in - self.ctx.mission_req_table[mission].required_world) - - if self.ctx.mission_req_table[mission].number: - tooltip += " and " - if self.ctx.mission_req_table[mission].number: - tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed" - remaining_location_names: typing.List[str] = [ - self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) - if loc in self.ctx.missing_locations] - - if mission_id == self.ctx.final_mission: - if mission in available_missions: - text = f"[color=FFBC95]{mission}[/color]" - else: - text = f"[color=D0C0BE]{mission}[/color]" - if tooltip: - tooltip += "\n" - tooltip += "Final Mission" - - if remaining_location_names: - if tooltip: - tooltip += "\n" - tooltip += f"Uncollected locations:\n" - tooltip += "\n".join(remaining_location_names) - - mission_button = MissionButton(text=text, size_hint_y=None, height=50) - mission_button.tooltip_text = tooltip - mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[mission_id] = mission_button - category_panel.add_widget(mission_button) - - category_panel.add_widget(Label(text="")) - self.mission_panel.add_widget(category_panel) - - elif self.launching: - self.refresh_from_launching = False - - self.mission_panel.clear_widgets() - self.mission_panel.add_widget(Label(text="Launching Mission: " + - lookup_id_to_mission[self.launching])) - if self.ctx.ui: - self.ctx.ui.clear_tooltip() - - def mission_callback(self, button): - if not self.launching: - mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button) - self.ctx.play_mission(mission_id) - self.launching = mission_id - Clock.schedule_once(self.finish_launching, 10) - - def finish_launching(self, dt): - self.launching = False - - self.ui = SC2Manager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - import pkgutil - data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode() - Builder.load_string(data) - - async def shutdown(self): - await super(SC2Context, self).shutdown() - if self.last_bot: - self.last_bot.want_close = True - if self.sc2_run_task: - self.sc2_run_task.cancel() - - def play_mission(self, mission_id: int): - if self.missions_unlocked or \ - is_mission_available(self, mission_id): - if self.sc2_run_task: - if not self.sc2_run_task.done(): - sc2_logger.warning("Starcraft 2 Client is still running!") - self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task - if self.slot is None: - sc2_logger.warning("Launching Mission without Archipelago authentication, " - "checks will not be registered to server.") - self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id), - name="Starcraft 2 Launch") - else: - sc2_logger.info( - f"{lookup_id_to_mission[mission_id]} is not currently unlocked. " - f"Use /unfinished or /available to see what is available.") - - def build_location_to_mission_mapping(self): - mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = { - mission_info.id: set() for mission_info in self.mission_req_table.values() - } - - for loc in self.server_locations: - mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo) - mission_id_to_location_ids[mission_id].add(objective) - self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in - mission_id_to_location_ids.items()} - - def locations_for_mission(self, mission: str): - mission_id: int = self.mission_req_table[mission].id - objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id] - for objective in objectives: - yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective - - -async def main(): - multiprocessing.freeze_support() - parser = get_base_parser() - parser.add_argument('--name', default=None, help="Slot Name to connect as.") - args = parser.parse_args() - - ctx = SC2Context(args.connect, args.password) - ctx.auth = args.name - if ctx.server_task is None: - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - await ctx.exit_event.wait() - - await ctx.shutdown() - - -maps_table = [ - "ap_traynor01", "ap_traynor02", "ap_traynor03", - "ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b", - "ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05", - "ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b", - "ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s", - "ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04", - "ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03" -] - -wol_default_categories = [ - "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist", - "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert", - "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", - "Char", "Char", "Char", "Char" -] -wol_default_category_names = [ - "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" -] - - -def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: - network_item: NetworkItem - accumulators: typing.List[int] = [0 for _ in type_flaggroups] - - for network_item in items: - name: str = lookup_id_to_name[network_item.item] - item_data: ItemData = item_table[name] - - # exists exactly once - if item_data.quantity == 1: - accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number - - # exists multiple times - elif item_data.type == "Upgrade": - accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number - - # sum - else: - accumulators[type_flaggroups[item_data.type]] += item_data.number - - return accumulators - - -def calc_difficulty(difficulty): - if difficulty == 0: - return 'C' - elif difficulty == 1: - return 'N' - elif difficulty == 2: - return 'H' - elif difficulty == 3: - return 'B' - - return 'X' - - -async def starcraft_launch(ctx: SC2Context, mission_id: int): - sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") - - with DllDirectory(None): - run_game(bot.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), - name="Archipelago", fullscreen=True)], realtime=True) - - -class ArchipelagoBot(bot.bot_ai.BotAI): - game_running: bool = False - mission_completed: bool = False - boni: typing.List[bool] - setup_done: bool - ctx: SC2Context - mission_id: int - want_close: bool = False - can_read_game = False - - last_received_update: int = 0 - - def __init__(self, ctx: SC2Context, mission_id): - self.setup_done = False - self.ctx = ctx - self.ctx.last_bot = self - self.mission_id = mission_id - self.boni = [False for _ in range(max_bonus)] - - super(ArchipelagoBot, self).__init__() - - async def on_step(self, iteration: int): - if self.want_close: - self.want_close = False - await self._client.leave() - return - game_state = 0 - if not self.setup_done: - self.setup_done = True - start_items = calculate_items(self.ctx.items_received) - if self.ctx.difficulty_override >= 0: - difficulty = calc_difficulty(self.ctx.difficulty_override) - else: - difficulty = calc_difficulty(self.ctx.difficulty) - await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format( - difficulty, - start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], - start_items[5], start_items[6], start_items[7], start_items[8], start_items[9], - self.ctx.all_in_choice, start_items[10])) - self.last_received_update = len(self.ctx.items_received) - - else: - if not self.ctx.announcements.empty(): - message = self.ctx.announcements.get(timeout=1) - await self.chat_send("SendMessage " + message) - self.ctx.announcements.task_done() - - # Archipelago reads the health - for unit in self.all_own_units(): - if unit.health_max == 38281: - game_state = int(38281 - unit.health) - self.can_read_game = True - - if iteration == 160 and not game_state & 1: - await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " + - "Starcraft 2 (This is likely a map issue)") - - if self.last_received_update < len(self.ctx.items_received): - current_items = calculate_items(self.ctx.items_received) - await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format( - current_items[0], current_items[1], current_items[2], current_items[3], current_items[4], - current_items[5], current_items[6], current_items[7])) - self.last_received_update = len(self.ctx.items_received) - - if game_state & 1: - if not self.game_running: - print("Archipelago Connected") - self.game_running = True - - if self.can_read_game: - if game_state & (1 << 1) and not self.mission_completed: - if self.mission_id != self.ctx.final_mission: - print("Mission Completed") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}]) - self.mission_completed = True - else: - print("Game Complete") - await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}]) - self.mission_completed = True - - for x, completed in enumerate(self.boni): - if not completed and game_state & (1 << (x + 2)): - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}]) - self.boni[x] = True - - else: - await self.chat_send("LostConnection - Lost connection to game.") - - -def request_unfinished_missions(ctx: SC2Context): - if ctx.mission_req_table: - message = "Unfinished Missions: " - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table) - - _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks) - - # Removing All-In from location pool - final_mission = lookup_id_to_mission[ctx.final_mission] - if final_mission in unfinished_missions.keys(): - message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message - if unfinished_missions[final_mission] == -1: - unfinished_missions.pop(final_mission) - - message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " + - mark_up_objectives( - f"[{len(unfinished_missions[mission])}/" - f"{sum(1 for _ in ctx.locations_for_mission(mission))}]", - ctx, unfinished_locations, mission) - for mission in unfinished_missions) - - if ctx.ui: - ctx.ui.log_panels['All'].on_message_markup(message) - ctx.ui.log_panels['Starcraft2'].on_message_markup(message) - else: - sc2_logger.info(message) - else: - sc2_logger.warning("No mission table found, you are likely not connected to a server.") - - -def calc_unfinished_missions(ctx: SC2Context, unlocks=None): - unfinished_missions = [] - locations_completed = [] - - if not unlocks: - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - - available_missions = calc_available_missions(ctx, unlocks) - - for name in available_missions: - objectives = set(ctx.locations_for_mission(name)) - if objectives: - objectives_completed = ctx.checked_locations & objectives - if len(objectives_completed) < len(objectives): - unfinished_missions.append(name) - locations_completed.append(objectives_completed) - - else: # infer that this is the final mission as it has no objectives - unfinished_missions.append(name) - locations_completed.append(-1) - - return available_missions, dict(zip(unfinished_missions, locations_completed)) - - -def is_mission_available(ctx: SC2Context, mission_id_to_check): - unfinished_missions = calc_available_missions(ctx) - - return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions) - - -def mark_up_mission_name(ctx: SC2Context, mission, unlock_table): - """Checks if the mission is required for game completion and adds '*' to the name to mark that.""" - - if ctx.mission_req_table[mission].completion_critical: - if ctx.ui: - message = "[color=AF99EF]" + mission + "[/color]" - else: - message = "*" + mission + "*" - else: - message = mission - - if ctx.ui: - unlocks = unlock_table[mission] - - if len(unlocks) > 0: - pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: " - pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks) - pre_message += f"]" - message = pre_message + message + "[/ref]" - - return message - - -def mark_up_objectives(message, ctx, unfinished_locations, mission): - formatted_message = message - - if ctx.ui: - locations = unfinished_locations[mission] - - pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|" - pre_message += "
".join(location for location in locations) - pre_message += f"]" - formatted_message = pre_message + message + "[/ref]" - - return formatted_message - - -def request_available_missions(ctx: SC2Context): - if ctx.mission_req_table: - message = "Available Missions: " - - # Initialize mission unlock table - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - - missions = calc_available_missions(ctx, unlocks) - message += \ - ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}" - f"[{ctx.mission_req_table[mission].id}]" - for mission in missions) - - if ctx.ui: - ctx.ui.log_panels['All'].on_message_markup(message) - ctx.ui.log_panels['Starcraft2'].on_message_markup(message) - else: - sc2_logger.info(message) - else: - sc2_logger.warning("No mission table found, you are likely not connected to a server.") - - -def calc_available_missions(ctx: SC2Context, unlocks=None): - available_missions = [] - missions_complete = 0 - - # Get number of missions completed - for loc in ctx.checked_locations: - if loc % victory_modulo == 0: - missions_complete += 1 - - for name in ctx.mission_req_table: - # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips - if unlocks: - for unlock in ctx.mission_req_table[name].required_world: - unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name) - - if mission_reqs_completed(ctx, name, missions_complete): - available_missions.append(name) - - return available_missions - - -def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): - """Returns a bool signifying if the mission has all requirements complete and can be done - - Arguments: - ctx -- instance of SC2Context - locations_to_check -- the mission string name to check - missions_complete -- an int of how many missions have been completed - mission_path -- a list of missions that have already been checked -""" - if len(ctx.mission_req_table[mission_name].required_world) >= 1: - # A check for when the requirements are being or'd - or_success = False - - # Loop through required missions - for req_mission in ctx.mission_req_table[mission_name].required_world: - req_success = True - - # Check if required mission has been completed - if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id * - victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations: - if not ctx.mission_req_table[mission_name].or_requirements: - return False - else: - req_success = False - - # Grid-specific logic (to avoid long path checks and infinite recursion) - if ctx.mission_order in (3, 4): - if req_success: - return True - else: - if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: - return False - else: - continue - - # Recursively check required mission to see if it's requirements are met, in case !collect has been done - # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion - if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): - if not ctx.mission_req_table[mission_name].or_requirements: - return False - else: - req_success = False - - # If requirement check succeeded mark or as satisfied - if ctx.mission_req_table[mission_name].or_requirements and req_success: - or_success = True - - if ctx.mission_req_table[mission_name].or_requirements: - # Return false if or requirements not met - if not or_success: - return False - - # Check number of missions - if missions_complete >= ctx.mission_req_table[mission_name].number: - return True - else: - return False - else: - return True - - -def initialize_blank_mission_dict(location_table): - unlocks = {} - - for mission in list(location_table): - unlocks[mission] = [] - - return unlocks - - -def check_game_install_path() -> bool: - # First thing: go to the default location for ExecuteInfo. - # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it. - if is_windows: - # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow. - # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555# - import ctypes.wintypes - CSIDL_PERSONAL = 5 # My Documents - SHGFP_TYPE_CURRENT = 0 # Get current, not default value - - buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) - ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf) - documentspath = buf.value - einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt")) - else: - einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF])) - - # Check if the file exists. - if os.path.isfile(einfo): - - # Open the file and read it, picking out the latest executable's path. - with open(einfo) as f: - content = f.read() - if content: - try: - base = re.search(r" = (.*)Versions", content).group(1) - except AttributeError: - sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then " - f"try again.") - return False - if os.path.exists(base): - executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions") - - # Finally, check the path for an actual executable. - # If we find one, great. Set up the SC2PATH. - if os.path.isfile(executable): - sc2_logger.info(f"Found an SC2 install at {base}!") - sc2_logger.debug(f"Latest executable at {executable}.") - os.environ["SC2PATH"] = base - sc2_logger.debug(f"SC2PATH set to {base}.") - return True - else: - sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.") - else: - sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.") - else: - sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. " - f"If that fails, please run /set_path with your SC2 install directory.") - return False - - -def is_mod_installed_correctly() -> bool: - """Searches for all required files.""" - if "SC2PATH" not in os.environ: - check_game_install_path() - - mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign') - modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod") - wol_required_maps = [ - "ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map", - "ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map", - "ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map", - "ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map", - "ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map", - "ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map", - "ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map" - ] - needs_files = False - - # Check for maps. - missing_maps = [] - for mapfile in wol_required_maps: - if not os.path.isfile(mapdir / mapfile): - missing_maps.append(mapfile) - if len(missing_maps) >= 19: - sc2_logger.warning(f"All map files missing from {mapdir}.") - needs_files = True - elif len(missing_maps) > 0: - for map in missing_maps: - sc2_logger.debug(f"Missing {map} from {mapdir}.") - sc2_logger.warning(f"Missing {len(missing_maps)} map files.") - needs_files = True - else: # Must be no maps missing - sc2_logger.info(f"All maps found in {mapdir}.") - - # Check for mods. - if os.path.isfile(modfile): - sc2_logger.info(f"Archipelago mod found at {modfile}.") - else: - sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.") - needs_files = True - - # Final verdict. - if needs_files: - sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.") - return False - else: - return True - - -class DllDirectory: - # Credit to Black Sliver for this code. - # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw - _old: typing.Optional[str] = None - _new: typing.Optional[str] = None - - def __init__(self, new: typing.Optional[str]): - self._new = new - - def __enter__(self): - old = self.get() - if self.set(self._new): - self._old = old - - def __exit__(self, *args): - if self._old is not None: - self.set(self._old) - - @staticmethod - def get() -> typing.Optional[str]: - if sys.platform == "win32": - n = ctypes.windll.kernel32.GetDllDirectoryW(0, None) - buf = ctypes.create_unicode_buffer(n) - ctypes.windll.kernel32.GetDllDirectoryW(n, buf) - return buf.value - # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific - return None - - @staticmethod - def set(s: typing.Optional[str]) -> bool: - if sys.platform == "win32": - return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0 - # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific - return False - - -def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str): - """Downloads the latest release of a GitHub repo to the current directory as a .zip file.""" - import requests - - headers = {"Accept": 'application/vnd.github.v3+json'} - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - - r1 = requests.get(url, headers=headers) - if r1.status_code == 200: - latest_version = r1.json()["tag_name"] - sc2_logger.info(f"Latest version: {latest_version}.") - else: - sc2_logger.warning(f"Status code: {r1.status_code}") - sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.") - sc2_logger.warning(f"text: {r1.text}") - return "", current_version - - if (force_download is False) and (current_version == latest_version): - sc2_logger.info("Latest version already installed.") - return "", current_version - - sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.") - download_url = r1.json()["assets"][0]["browser_download_url"] - - r2 = requests.get(download_url, headers=headers) - if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)): - with open(f"{repo}.zip", "wb") as fh: - fh.write(r2.content) - sc2_logger.info(f"Successfully downloaded {repo}.zip.") - return f"{repo}.zip", latest_version - else: - sc2_logger.warning(f"Status code: {r2.status_code}") - sc2_logger.warning("Download failed.") - sc2_logger.warning(f"text: {r2.text}") - return "", current_version - - -def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool: - import requests - - headers = {"Accept": 'application/vnd.github.v3+json'} - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - - r1 = requests.get(url, headers=headers) - if r1.status_code == 200: - latest_version = r1.json()["tag_name"] - if current_version != latest_version: - return True - else: - return False - - else: - sc2_logger.warning(f"Failed to reach GitHub while checking for updates.") - sc2_logger.warning(f"Status code: {r1.status_code}") - sc2_logger.warning(f"text: {r1.text}") - return False - - -if __name__ == '__main__': - colorama.init() - asyncio.run(main()) - colorama.deinit() + Utils.init_logging("Starcraft2Client", exception_logger="Client") + launch() diff --git a/UndertaleClient.py b/UndertaleClient.py index 6419707211a6..62fbe128bdb9 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -29,31 +29,31 @@ def _cmd_resync(self): def _cmd_patch(self): """Patch the game.""" if isinstance(self.ctx, UndertaleContext): - os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) self.ctx.patch_game() self.output("Patched.") def _cmd_savepath(self, directory: str): """Redirect to proper save data folder. (Use before connecting!)""" if isinstance(self.ctx, UndertaleContext): - UndertaleContext.save_game_folder = directory - self.output("Changed to the following directory: " + directory) + self.ctx.save_game_folder = directory + self.output("Changed to the following directory: " + self.ctx.save_game_folder) @mark_raw def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): """Patch the game automatically.""" if isinstance(self.ctx, UndertaleContext): - os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) tempInstall = steaminstall if not os.path.isfile(os.path.join(tempInstall, "data.win")): tempInstall = None if tempInstall is None: tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" - if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + if not os.path.exists(tempInstall): tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" elif not os.path.exists(tempInstall): tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" - if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + if not os.path.exists(tempInstall): tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")): self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder." @@ -61,8 +61,8 @@ def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): else: for file_name in os.listdir(tempInstall): if file_name != "steam_api.dll": - shutil.copy(tempInstall+"\\"+file_name, - os.getcwd() + "\\Undertale\\" + file_name) + shutil.copy(os.path.join(tempInstall, file_name), + os.path.join(os.getcwd(), "Undertale", file_name)) self.ctx.patch_game() self.output("Patching successful!") @@ -111,13 +111,13 @@ def __init__(self, server_address, password): self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") def patch_game(self): - with open(os.getcwd() + "/Undertale/data.win", "rb") as f: + with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) - with open(os.getcwd() + "/Undertale/data.win", "wb") as f: + with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: f.write(patchedFile) - os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True) - with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" + - "Which Character.txt"), "w") as f: + os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) + with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", + "Which Character.txt")), "w") as f: f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " "line other than this one.\n", "frisk"]) f.close() @@ -385,7 +385,7 @@ async def multi_watcher(ctx: UndertaleContext): for root, dirs, files in os.walk(path): for file in files: if "spots.mine" in file and "Online" in ctx.tags: - with open(root + "/" + file, "r") as mine: + with open(os.path.join(root, file), "r") as mine: this_x = mine.readline() this_y = mine.readline() this_room = mine.readline() @@ -408,7 +408,7 @@ async def game_watcher(ctx: UndertaleContext): for root, dirs, files in os.walk(path): for file in files: if ".item" in file: - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) sync_msg = [{"cmd": "Sync"}] if ctx.locations_checked: sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) @@ -424,13 +424,13 @@ async def game_watcher(ctx: UndertaleContext): for root, dirs, files in os.walk(path): for file in files: if "DontBeMad.mad" in file: - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) if "DeathLink" in ctx.tags: await ctx.send_death() if "scout" == file: sending = [] try: - with open(root+"/"+file, "r") as f: + with open(os.path.join(root, file), "r") as f: lines = f.readlines() for l in lines: if ctx.server_locations.__contains__(int(l)+12000): @@ -438,11 +438,11 @@ async def game_watcher(ctx: UndertaleContext): finally: await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, "create_as_hint": int(2)}]) - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) if "check.spot" in file: sending = [] try: - with open(root+"/"+file, "r") as f: + with open(os.path.join(root, file), "r") as f: lines = f.readlines() for l in lines: sending = sending+[(int(l.rstrip('\n')))+12000] @@ -451,7 +451,7 @@ async def game_watcher(ctx: UndertaleContext): if "victory" in file and str(ctx.route) in file: victory = True if ".playerspot" in file and "Online" not in ctx.tags: - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) if "victory" in file: if str(ctx.route) == "all_routes": if "neutral" in file and ctx.completed_routes["neutral"] != 1: diff --git a/Utils.py b/Utils.py index 159c6cdcb161..5fb037a17325 100644 --- a/Utils.py +++ b/Utils.py @@ -13,6 +13,7 @@ import collections import importlib import logging +import warnings from argparse import Namespace from settings import Settings, get_settings @@ -29,6 +30,7 @@ if typing.TYPE_CHECKING: import tkinter import pathlib + from BaseClasses import Region def tuplize_version(version: str) -> Version: @@ -44,7 +46,7 @@ def as_simple_string(self) -> str: return ".".join(str(item) for item in self) -__version__ = "0.4.2" +__version__ = "0.4.3" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -215,7 +217,13 @@ def get_cert_none_ssl_context(): def get_public_ipv4() -> str: import socket import urllib.request - ip = socket.gethostbyname(socket.gethostname()) + try: + ip = socket.gethostbyname(socket.gethostname()) + except socket.gaierror: + # if hostname or resolvconf is not set up properly, this may fail + warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1") + ip = "127.0.0.1" + ctx = get_cert_none_ssl_context() try: ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() @@ -233,7 +241,13 @@ def get_public_ipv4() -> str: def get_public_ipv6() -> str: import socket import urllib.request - ip = socket.gethostbyname(socket.gethostname()) + try: + ip = socket.gethostbyname(socket.gethostname()) + except socket.gaierror: + # if hostname or resolvconf is not set up properly, this may fail + warnings.warn("Could not resolve own hostname, falling back to ::1") + ip = "::1" + ctx = get_cert_none_ssl_context() try: ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() @@ -359,11 +373,13 @@ def get_unique_identifier(): class RestrictedUnpickler(pickle.Unpickler): + generic_properties_module: Optional[object] + def __init__(self, *args, **kwargs): super(RestrictedUnpickler, self).__init__(*args, **kwargs) self.options_module = importlib.import_module("Options") self.net_utils_module = importlib.import_module("NetUtils") - self.generic_properties_module = importlib.import_module("worlds.generic") + self.generic_properties_module = None def find_class(self, module, name): if module == "builtins" and name in safe_builtins: @@ -373,6 +389,8 @@ def find_class(self, module, name): return getattr(self.net_utils_module, name) # Options and Plando are unpickled by WebHost -> Generate if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: + if not self.generic_properties_module: + self.generic_properties_module = importlib.import_module("worlds.generic") return getattr(self.generic_properties_module, name) # pep 8 specifies that modules should have "all-lowercase names" (options, not Options) if module.lower().endswith("options"): @@ -441,11 +459,21 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri write_mode, encoding="utf-8-sig") file_handler.setFormatter(logging.Formatter(log_format)) + + class Filter(logging.Filter): + def __init__(self, filter_name, condition): + super().__init__(filter_name) + self.condition = condition + + def filter(self, record: logging.LogRecord) -> bool: + return self.condition(record) + + file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) root_logger.addHandler(file_handler) if sys.stdout: - root_logger.addHandler( - logging.StreamHandler(sys.stdout) - ) + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) + root_logger.addHandler(stream_handler) # Relay unhandled exceptions to logger. if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified @@ -572,7 +600,7 @@ def run(*args: str): zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - selection = (f'--filename="{suggest}',) if suggest else () + selection = (f"--filename={suggest}",) if suggest else () return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk @@ -584,7 +612,10 @@ def run(*args: str): f'This attempt was made because open_filename was used for "{title}".') raise e else: - root = tkinter.Tk() + try: + root = tkinter.Tk() + except tkinter.TclError: + return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), initialfile=suggest or None) @@ -597,13 +628,14 @@ def run(*args: str): if is_linux: # prefer native dialog from shutil import which - kdialog = None#which("kdialog") + kdialog = which("kdialog") if kdialog: - return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".") - zenity = None#which("zenity") + return run(kdialog, f"--title={title}", "--getexistingdirectory", + os.path.abspath(suggest) if suggest else ".") + zenity = which("zenity") if zenity: z_filters = ("--directory",) - selection = (f'--filename="{suggest}',) if suggest else () + selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk @@ -615,7 +647,10 @@ def run(*args: str): f'This attempt was made because open_filename was used for "{title}".') raise e else: - root = tkinter.Tk() + try: + root = tkinter.Tk() + except tkinter.TclError: + return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) @@ -645,6 +680,11 @@ def is_kivy_running(): if zenity: return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") + elif is_windows: + import ctypes + style = 0x10 if error else 0x0 + return ctypes.windll.user32.MessageBoxW(0, text, title, style) + # fall back to tk try: import tkinter @@ -755,3 +795,113 @@ def freeze_support() -> None: import multiprocessing _extend_freeze_support() multiprocessing.freeze_support() + + +def visualize_regions(root_region: Region, file_name: str, *, + show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, + linetype_ortho: bool = True) -> None: + """Visualize the layout of a world as a PlantUML diagram. + + :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) + :param file_name: The name of the destination .puml file. + :param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection. + :param show_locations: (default True) If enabled, the locations will be listed inside each region. + Priority locations will be shown in bold. + Excluded locations will be stricken out. + Locations without ID will be shown in italics. + Locked locations will be shown with a padlock icon. + For filled locations, the item name will be shown after the location name. + Progression items will be shown in bold. + Items without ID will be shown in italics. + :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. + :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. + + Example usage in World code: + from Utils import visualize_regions + visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") + + Example usage in Main code: + from Utils import visualize_regions + for player in world.player_ids: + visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml") + """ + assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" + from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region + from collections import deque + import re + + uml: typing.List[str] = list() + seen: typing.Set[Region] = set() + regions: typing.Deque[Region] = deque((root_region,)) + multiworld: MultiWorld = root_region.multiworld + + def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: + name = obj.name + if isinstance(obj, Item): + name = multiworld.get_name_string_for_object(obj) + if obj.advancement: + name = f"**{name}**" + if obj.code is None: + name = f"//{name}//" + if isinstance(obj, Location): + if obj.progress_type == LocationProgressType.PRIORITY: + name = f"**{name}**" + elif obj.progress_type == LocationProgressType.EXCLUDED: + name = f"--{name}--" + if obj.address is None: + name = f"//{name}//" + return re.sub("[\".:]", "", name) + + def visualize_exits(region: Region) -> None: + for exit_ in region.exits: + if exit_.connected_region: + if show_entrance_names: + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"") + else: + try: + uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"") + uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"") + except ValueError: + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"") + else: + uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"") + uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"") + + def visualize_locations(region: Region) -> None: + any_lock = any(location.locked for location in region.locations) + for location in region.locations: + lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else "" + if location.item: + uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}") + else: + uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") + + def visualize_region(region: Region) -> None: + uml.append(f"class \"{fmt(region)}\"") + if show_locations: + visualize_locations(region) + visualize_exits(region) + + def visualize_other_regions() -> None: + if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]: + uml.append("package \"other regions\" <> {") + for region in other_regions: + uml.append(f"class \"{fmt(region)}\"") + uml.append("}") + + uml.append("@startuml") + uml.append("hide circle") + uml.append("hide empty members") + if linetype_ortho: + uml.append("skinparam linetype ortho") + while regions: + if (current_region := regions.popleft()) not in seen: + seen.add(current_region) + visualize_region(current_region) + regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region) + if show_other_regions: + visualize_other_regions() + uml.append("@enduml") + + with open(file_name, "wt", encoding="utf-8") as f: + f.write("\n".join(uml)) diff --git a/WebHost.py b/WebHost.py index 45d017cf1f67..8595fa7a27a4 100644 --- a/WebHost.py +++ b/WebHost.py @@ -13,15 +13,6 @@ import settings Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 - -from WebHostLib import register, app as raw_app -from waitress import serve - -from WebHostLib.models import db -from WebHostLib.autolauncher import autohost, autogen -from WebHostLib.lttpsprites import update_sprites_lttp -from WebHostLib.options import create as create_options_files - settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home @@ -29,6 +20,9 @@ def get_app(): + from WebHostLib import register, cache, app as raw_app + from WebHostLib.models import db + register() app = raw_app if os.path.exists(configpath) and not app.config["TESTING"]: @@ -40,6 +34,7 @@ def get_app(): app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") + cache.init_app(app) db.bind(**app.config["PONY"]) db.generate_mapping(create_tables=True) return app @@ -120,6 +115,11 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] multiprocessing.freeze_support() multiprocessing.set_start_method('spawn') logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) + + from WebHostLib.lttpsprites import update_sprites_lttp + from WebHostLib.autolauncher import autohost, autogen + from WebHostLib.options import create as create_options_files + try: update_sprites_lttp() except Exception as e: @@ -136,4 +136,5 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] if app.config["DEBUG"]: app.run(debug=True, port=app.config["PORT"]) else: + from waitress import serve serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index a59e3aa553f3..43ca89f0b3f3 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -49,11 +49,10 @@ 'create_db': True } app.config["MAX_ROLL"] = 20 -app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache" -app.config["JSON_AS_ASCII"] = False +app.config["CACHE_TYPE"] = "SimpleCache" app.config["HOST_ADDRESS"] = "" -cache = Cache(app) +cache = Cache() Compress(app) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 0475a6329727..90838671200c 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -3,8 +3,6 @@ import json import logging import multiprocessing -import os -import sys import threading import time import typing @@ -13,55 +11,7 @@ from pony.orm import db_session, select, commit from Utils import restricted_loads - - -class CommonLocker(): - """Uses a file lock to signal that something is already running""" - lock_folder = "file_locks" - - def __init__(self, lockname: str, folder=None): - if folder: - self.lock_folder = folder - os.makedirs(self.lock_folder, exist_ok=True) - self.lockname = lockname - self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck") - - -class AlreadyRunningException(Exception): - pass - - -if sys.platform == 'win32': - class Locker(CommonLocker): - def __enter__(self): - try: - if os.path.exists(self.lockfile): - os.unlink(self.lockfile) - self.fp = os.open( - self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) - except OSError as e: - raise AlreadyRunningException() from e - - def __exit__(self, _type, value, tb): - fp = getattr(self, "fp", None) - if fp: - os.close(self.fp) - os.unlink(self.lockfile) -else: # unix - import fcntl - - - class Locker(CommonLocker): - def __enter__(self): - try: - self.fp = open(self.lockfile, "wb") - fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError as e: - raise AlreadyRunningException() from e - - def __exit__(self, _type, value, tb): - fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN) - self.fp.close() +from .locker import Locker, AlreadyRunningException def launch_room(room: Room, config: dict): diff --git a/WebHostLib/check.py b/WebHostLib/check.py index 0c1e090dbe47..4db2ec2ce35e 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,5 +1,6 @@ import zipfile -from typing import * +import base64 +from typing import Union, Dict, Set, Tuple from flask import request, flash, redirect, url_for, render_template from markupsafe import Markup @@ -24,13 +25,21 @@ def check(): if 'file' not in request.files: flash('No file part') else: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, str): flash(options) else: results, _ = roll_options(options) - return render_template("checkResult.html", results=results) + if len(options) > 1: + # offer combined file back + combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}" + for file_name, file_content in options.items()) + combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode() + else: + combined_yaml = "" + return render_template("checkResult.html", + results=results, combined_yaml=combined_yaml) return render_template("check.html") @@ -39,30 +48,34 @@ def mysterycheck(): return redirect(url_for("check"), 301) -def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]: +def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '': - return 'No selected file' - elif file and allowed_file(file.filename): - if file.filename.endswith(".zip"): - - with zipfile.ZipFile(file, 'r') as zfile: - infolist = zfile.infolist() - - if any(file.filename.endswith(".archipelago") for file in infolist): - return Markup("Error: Your .zip file contains an .archipelago file. " - 'Did you mean to host a game?') - - for file in infolist: - if file.filename.endswith(banned_zip_contents): - return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ - "Your file was deleted." - elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): - options[file.filename] = zfile.open(file, "r").read() - else: - options = {file.filename: file.read()} + for uploaded_file in files: + # if user does not select file, browser also + # submit an empty part without filename + if uploaded_file.filename == '': + return 'No selected file' + elif uploaded_file.filename in options: + return f'Conflicting files named {uploaded_file.filename} submitted' + elif uploaded_file and allowed_file(uploaded_file.filename): + if uploaded_file.filename.endswith(".zip"): + + with zipfile.ZipFile(uploaded_file, 'r') as zfile: + infolist = zfile.infolist() + + if any(file.filename.endswith(".archipelago") for file in infolist): + return Markup("Error: Your .zip file contains an .archipelago file. " + 'Did you mean to host a game?') + + for file in infolist: + if file.filename.endswith(banned_zip_contents): + return ("Uploaded data contained a rom file, " + "which is likely to contain copyrighted material. " + "Your file was deleted.") + elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): + options[file.filename] = zfile.open(file, "r").read() + else: + options[uploaded_file.filename] = uploaded_file.read() if not options: return "Did not find a .yaml file to process." return options diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 8fbf692dec20..6d633314b2be 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -11,6 +11,7 @@ import threading import time import typing +import sys import websockets from pony.orm import commit, db_session, select @@ -19,6 +20,7 @@ from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert from Utils import restricted_loads, cache_argsless +from .locker import Locker from .models import Command, GameDataPackage, Room, db @@ -163,16 +165,21 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, db.generate_mapping(check_tables=False) async def main(): + if "worlds" in sys.modules: + raise Exception("Worlds system should not be loaded in the custom server.") + + import gc Utils.init_logging(str(room_id), write_mode="a") ctx = WebHostContext(static_server_data) ctx.load(room_id) ctx.init_save() ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None + gc.collect() # free intermediate objects used during setup try: ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) await ctx.server - except Exception: # likely port in use - in windows this is OSError, but I didn't check the others + except OSError: # likely port in use ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) await ctx.server @@ -198,16 +205,15 @@ async def main(): await ctx.shutdown_task logging.info("Shutting down") - from .autolauncher import Locker with Locker(room_id): try: asyncio.run(main()) - except KeyboardInterrupt: + except (KeyboardInterrupt, SystemExit): with db_session: room = Room.get(id=room_id) # ensure the Room does not spin up again on its own, minute of safety buffer room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) - except: + except Exception: with db_session: room = Room.get(id=room_id) room.last_port = -1 diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 91d7594a1f23..ddcc5ffb6c7b 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -64,8 +64,8 @@ def generate(race=False): if 'file' not in request.files: flash('No file part') else: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, str): flash(options) else: diff --git a/WebHostLib/locker.py b/WebHostLib/locker.py new file mode 100644 index 000000000000..5293352887d3 --- /dev/null +++ b/WebHostLib/locker.py @@ -0,0 +1,51 @@ +import os +import sys + + +class CommonLocker: + """Uses a file lock to signal that something is already running""" + lock_folder = "file_locks" + + def __init__(self, lockname: str, folder=None): + if folder: + self.lock_folder = folder + os.makedirs(self.lock_folder, exist_ok=True) + self.lockname = lockname + self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck") + + +class AlreadyRunningException(Exception): + pass + + +if sys.platform == 'win32': + class Locker(CommonLocker): + def __enter__(self): + try: + if os.path.exists(self.lockfile): + os.unlink(self.lockfile) + self.fp = os.open( + self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) + except OSError as e: + raise AlreadyRunningException() from e + + def __exit__(self, _type, value, tb): + fp = getattr(self, "fp", None) + if fp: + os.close(self.fp) + os.unlink(self.lockfile) +else: # unix + import fcntl + + + class Locker(CommonLocker): + def __enter__(self): + try: + self.fp = open(self.lockfile, "wb") + fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as e: + raise AlreadyRunningException() from e + + def __exit__(self, _type, value, tb): + fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN) + self.fp.close() diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 6d3e82c00c6d..e3111ed5b53c 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -32,29 +32,34 @@ def page_not_found(err): # Start Playing Page @app.route('/start-playing') +@cache.cached() def start_playing(): return render_template(f"startPlaying.html") @app.route('/weighted-settings') +@cache.cached() def weighted_settings(): return render_template(f"weighted-settings.html") # Player settings pages @app.route('/games//player-settings') +@cache.cached() def player_settings(game): return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) # Game Info Pages @app.route('/games//info/') +@cache.cached() def game_info(game, lang): return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) # List of supported games @app.route('/games') +@cache.cached() def games(): worlds = {} for game, world in AutoWorldRegister.world_types.items(): @@ -64,21 +69,25 @@ def games(): @app.route('/tutorial///') +@cache.cached() def tutorial(game, file, lang): return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) @app.route('/tutorial/') +@cache.cached() def tutorial_landing(): return render_template("tutorialLanding.html") @app.route('/faq//') +@cache.cached() def faq(lang): return render_template("faq.html", lang=lang) @app.route('/glossary//') +@cache.cached() def terms(lang): return render_template("glossary.html", lang=lang) @@ -147,7 +156,7 @@ def host_room(room: UUID): @app.route('/favicon.ico') def favicon(): - return send_from_directory(os.path.join(app.root_path, 'static/static'), + return send_from_directory(os.path.join(app.root_path, "static", "static"), 'favicon.ico', mimetype='image/vnd.microsoft.icon') @@ -167,6 +176,7 @@ def get_datapackage(): @app.route('/index') @app.route('/sitemap') +@cache.cached() def get_sitemap(): available_games: List[Dict[str, Union[str, bool]]] = [] for game, world in AutoWorldRegister.world_types.items(): diff --git a/WebHostLib/options.py b/WebHostLib/options.py index fca01407e06b..18a28045ee22 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -36,10 +36,7 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for game_name, world in AutoWorldRegister.world_types.items(): - all_options: typing.Dict[str, Options.AssembleOptions] = { - **Options.per_game_common_options, - **world.option_definitions - } + all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints # Generate JSON files for player-settings pages player_settings = { diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index a8b2865aae34..654104252cec 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,9 +1,9 @@ -flask>=2.2.3 -pony>=0.7.16; python_version <= '3.10' -pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11' +flask>=3.0.0 +pony>=0.7.17 waitress>=2.1.2 -Flask-Caching>=2.0.2 -Flask-Compress>=1.13 -Flask-Limiter>=3.3.0 -bokeh>=3.1.1 +Flask-Caching>=2.1.0 +Flask-Compress>=1.14 +Flask-Limiter>=3.5.0 +bokeh>=3.1.1; python_version <= '3.8' +bokeh>=3.2.2; python_version >= '3.9' markupsafe>=2.1.3 diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js index f75ba9060303..4ebec1adbf89 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-settings.js @@ -311,8 +311,7 @@ const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { optionalSelectElement.disabled = true; } } - - updateGameSetting(randomButton); + updateGameSetting(active ? inputElement : randomButton); }; const updateBaseSetting = (event) => { @@ -324,7 +323,6 @@ const updateBaseSetting = (event) => { const updateGameSetting = (settingElement) => { const options = JSON.parse(localStorage.getItem(gameName)); - if (settingElement.classList.contains('randomize-button')) { // If the event passed in is the randomize button, then we know what we must do. options[gameName][settingElement.getAttribute('data-key')] = 'random'; diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js new file mode 100644 index 000000000000..56eb15b5e580 --- /dev/null +++ b/WebHostLib/static/assets/supportedGames.js @@ -0,0 +1,64 @@ +window.addEventListener('load', () => { + // Add toggle listener to all elements with .collapse-toggle + const toggleButtons = document.querySelectorAll('.collapse-toggle'); + toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse)); + + // Handle game filter input + const gameSearch = document.getElementById('game-search'); + gameSearch.value = ''; + gameSearch.addEventListener('input', (evt) => { + if (!evt.target.value.trim()) { + // If input is empty, display all collapsed games + return toggleButtons.forEach((header) => { + header.style.display = null; + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); + }); + } + + // Loop over all the games + toggleButtons.forEach((header) => { + // If the game name includes the search string, display the game. If not, hide it + if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) { + header.style.display = null; + header.firstElementChild.innerText = '▼'; + header.nextElementSibling.classList.remove('collapsed'); + } else { + header.style.display = 'none'; + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); + } + }); + }); + + document.getElementById('expand-all').addEventListener('click', expandAll); + document.getElementById('collapse-all').addEventListener('click', collapseAll); +}); + +const toggleCollapse = (evt) => { + const gameArrow = evt.target.firstElementChild; + const gameInfo = evt.target.nextElementSibling; + if (gameInfo.classList.contains('collapsed')) { + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + } else { + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + } +}; + +const expandAll = () => { + document.querySelectorAll('.collapse-toggle').forEach((header) => { + if (header.style.display === 'none') { return; } + header.firstElementChild.innerText = '▼'; + header.nextElementSibling.classList.remove('collapsed'); + }); +}; + +const collapseAll = () => { + document.querySelectorAll('.collapse-toggle').forEach((header) => { + if (header.style.display === 'none') { return; } + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); + }); +}; diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index 6e86d470f05c..2cd61d2e6e5b 100644 --- a/WebHostLib/static/assets/weighted-settings.js +++ b/WebHostLib/static/assets/weighted-settings.js @@ -1,14 +1,14 @@ window.addEventListener('load', () => { - fetchSettingData().then((results) => { + fetchSettingData().then((data) => { let settingHash = localStorage.getItem('weighted-settings-hash'); if (!settingHash) { // If no hash data has been set before, set it now - settingHash = md5(JSON.stringify(results)); + settingHash = md5(JSON.stringify(data)); localStorage.setItem('weighted-settings-hash', settingHash); localStorage.removeItem('weighted-settings'); } - if (settingHash !== md5(JSON.stringify(results))) { + if (settingHash !== md5(JSON.stringify(data))) { const userMessage = document.getElementById('user-message'); userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " + "them all to default."; @@ -17,23 +17,22 @@ window.addEventListener('load', () => { } // Page setup - createDefaultSettings(results); - buildUI(results); - updateVisibleGames(); + const settings = new WeightedSettings(data); + settings.buildUI(); + settings.updateVisibleGames(); adjustHeaderWidth(); // Event listeners - document.getElementById('export-settings').addEventListener('click', () => exportSettings()); - document.getElementById('generate-race').addEventListener('click', () => generateGame(true)); - document.getElementById('generate-game').addEventListener('click', () => generateGame()); + document.getElementById('export-settings').addEventListener('click', () => settings.export()); + document.getElementById('generate-race').addEventListener('click', () => settings.generateGame(true)); + document.getElementById('generate-game').addEventListener('click', () => settings.generateGame()); // Name input field - const weightedSettings = JSON.parse(localStorage.getItem('weighted-settings')); const nameInput = document.getElementById('player-name'); nameInput.setAttribute('data-type', 'data'); nameInput.setAttribute('data-setting', 'name'); - nameInput.addEventListener('keyup', updateBaseSetting); - nameInput.value = weightedSettings.name; + nameInput.addEventListener('keyup', (evt) => settings.updateBaseSetting(evt)); + nameInput.value = settings.current.name; }); }); @@ -50,48 +49,65 @@ const fetchSettingData = () => new Promise((resolve, reject) => { }); }); -const createDefaultSettings = (settingData) => { - if (!localStorage.getItem('weighted-settings')) { - const newSettings = {}; +/// The weighted settings across all games. +class WeightedSettings { + // The data from the server describing the types of settings available for + // each game, as a JSON-safe blob. + data; + + // The settings chosen by the user as they'd appear in the YAML file, stored + // to and retrieved from local storage. + current; + + // A record mapping game names to the associated GameSettings. + games; + + constructor(data) { + this.data = data; + this.current = JSON.parse(localStorage.getItem('weighted-settings')); + this.games = Object.keys(this.data.games).map((game) => new GameSettings(this, game)); + if (this.current) { return; } + + this.current = {}; // Transfer base options directly - for (let baseOption of Object.keys(settingData.baseOptions)){ - newSettings[baseOption] = settingData.baseOptions[baseOption]; + for (let baseOption of Object.keys(this.data.baseOptions)){ + this.current[baseOption] = this.data.baseOptions[baseOption]; } // Set options per game - for (let game of Object.keys(settingData.games)) { + for (let game of Object.keys(this.data.games)) { // Initialize game object - newSettings[game] = {}; + this.current[game] = {}; // Transfer game settings - for (let gameSetting of Object.keys(settingData.games[game].gameSettings)){ - newSettings[game][gameSetting] = {}; + for (let gameSetting of Object.keys(this.data.games[game].gameSettings)){ + this.current[game][gameSetting] = {}; - const setting = settingData.games[game].gameSettings[gameSetting]; + const setting = this.data.games[game].gameSettings[gameSetting]; switch(setting.type){ case 'select': setting.options.forEach((option) => { - newSettings[game][gameSetting][option.value] = + this.current[game][gameSetting][option.value] = (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0; }); break; case 'range': case 'special_range': - newSettings[game][gameSetting]['random'] = 0; - newSettings[game][gameSetting]['random-low'] = 0; - newSettings[game][gameSetting]['random-high'] = 0; + this.current[game][gameSetting]['random'] = 0; + this.current[game][gameSetting]['random-low'] = 0; + this.current[game][gameSetting]['random-high'] = 0; if (setting.hasOwnProperty('defaultValue')) { - newSettings[game][gameSetting][setting.defaultValue] = 25; + this.current[game][gameSetting][setting.defaultValue] = 25; } else { - newSettings[game][gameSetting][setting.min] = 25; + this.current[game][gameSetting][setting.min] = 25; } break; case 'items-list': case 'locations-list': case 'custom-list': - newSettings[game][gameSetting] = setting.defaultValue; + this.current[game][gameSetting] = setting.defaultValue; break; default: @@ -99,33 +115,301 @@ const createDefaultSettings = (settingData) => { } } - newSettings[game].start_inventory = {}; - newSettings[game].exclude_locations = []; - newSettings[game].priority_locations = []; - newSettings[game].local_items = []; - newSettings[game].non_local_items = []; - newSettings[game].start_hints = []; - newSettings[game].start_location_hints = []; + this.current[game].start_inventory = {}; + this.current[game].exclude_locations = []; + this.current[game].priority_locations = []; + this.current[game].local_items = []; + this.current[game].non_local_items = []; + this.current[game].start_hints = []; + this.current[game].start_location_hints = []; } - localStorage.setItem('weighted-settings', JSON.stringify(newSettings)); + this.save(); } -}; -const buildUI = (settingData) => { - // Build the game-choice div - buildGameChoice(settingData.games); + // Saves the current settings to local storage. + save() { + localStorage.setItem('weighted-settings', JSON.stringify(this.current)); + } + + buildUI() { + // Build the game-choice div + this.#buildGameChoice(); + + const gamesWrapper = document.getElementById('games-wrapper'); + this.games.forEach((game) => { + gamesWrapper.appendChild(game.buildUI()); + }); + } + + #buildGameChoice() { + const gameChoiceDiv = document.getElementById('game-choice'); + const h2 = document.createElement('h2'); + h2.innerText = 'Game Select'; + gameChoiceDiv.appendChild(h2); + + const gameSelectDescription = document.createElement('p'); + gameSelectDescription.classList.add('setting-description'); + gameSelectDescription.innerText = 'Choose which games you might be required to play.'; + gameChoiceDiv.appendChild(gameSelectDescription); + + const hintText = document.createElement('p'); + hintText.classList.add('hint-text'); + hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' + + 'to that section.' + gameChoiceDiv.appendChild(hintText); + + // Build the game choice table + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + + Object.keys(this.data.games).forEach((game) => { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + const span = document.createElement('span'); + span.innerText = game; + span.setAttribute('id', `${game}-game-option`) + tdLeft.appendChild(span); + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.setAttribute('data-type', 'weight'); + range.setAttribute('data-setting', 'game'); + range.setAttribute('data-option', game); + range.value = this.current.game[game]; + range.addEventListener('change', (evt) => { + this.updateBaseSetting(evt); + this.updateVisibleGames(); // Show or hide games based on the new settings + }); + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `game-${game}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + gameChoiceDiv.appendChild(table); + } + + // Verifies that `this.settings` meets all the requirements for world + // generation, normalizes it for serialization, and returns the result. + #validateSettings() { + const settings = structuredClone(this.current); + const userMessage = document.getElementById('user-message'); + let errorMessage = null; + + // User must choose a name for their file + if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') { + userMessage.innerText = 'You forgot to set your player name at the top of the page!'; + userMessage.classList.add('visible'); + userMessage.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + return; + } + + // Clean up the settings output + Object.keys(settings.game).forEach((game) => { + // Remove any disabled games + if (settings.game[game] === 0) { + delete settings.game[game]; + delete settings[game]; + return; + } + + Object.keys(settings[game]).forEach((setting) => { + // Remove any disabled options + Object.keys(settings[game][setting]).forEach((option) => { + if (settings[game][setting][option] === 0) { + delete settings[game][setting][option]; + } + }); + + if ( + Object.keys(settings[game][setting]).length === 0 && + !Array.isArray(settings[game][setting]) && + setting !== 'start_inventory' + ) { + errorMessage = `${game} // ${setting} has no values above zero!`; + } + + // Remove weights from options with only one possibility + if ( + Object.keys(settings[game][setting]).length === 1 && + !Array.isArray(settings[game][setting]) && + setting !== 'start_inventory' + ) { + settings[game][setting] = Object.keys(settings[game][setting])[0]; + } + + // Remove empty arrays + else if ( + ['exclude_locations', 'priority_locations', 'local_items', + 'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) && + settings[game][setting].length === 0 + ) { + delete settings[game][setting]; + } + + // Remove empty start inventory + else if ( + setting === 'start_inventory' && + Object.keys(settings[game]['start_inventory']).length === 0 + ) { + delete settings[game]['start_inventory']; + } + }); + }); + + if (Object.keys(settings.game).length === 0) { + errorMessage = 'You have not chosen a game to play!'; + } + + // Remove weights if there is only one game + else if (Object.keys(settings.game).length === 1) { + settings.game = Object.keys(settings.game)[0]; + } + + // If an error occurred, alert the user and do not export the file + if (errorMessage) { + userMessage.innerText = errorMessage; + userMessage.classList.add('visible'); + userMessage.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + return; + } + + // If no error occurred, hide the user message if it is visible + userMessage.classList.remove('visible'); + return settings; + } + + updateVisibleGames() { + Object.entries(this.current.game).forEach(([game, weight]) => { + const gameDiv = document.getElementById(`${game}-div`); + const gameOption = document.getElementById(`${game}-game-option`); + if (parseInt(weight, 10) > 0) { + gameDiv.classList.remove('invisible'); + gameOption.classList.add('jump-link'); + gameOption.addEventListener('click', () => { + const gameDiv = document.getElementById(`${game}-div`); + if (gameDiv.classList.contains('invisible')) { return; } + gameDiv.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }); + } else { + gameDiv.classList.add('invisible'); + gameOption.classList.remove('jump-link'); + } + }); + } + + updateBaseSetting(event) { + const setting = event.target.getAttribute('data-setting'); + const option = event.target.getAttribute('data-option'); + const type = event.target.getAttribute('data-type'); + + switch(type){ + case 'weight': + this.current[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); + document.getElementById(`${setting}-${option}`).innerText = event.target.value; + break; + case 'data': + this.current[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); + break; + } + + this.save(); + } + + export() { + const settings = this.#validateSettings(); + if (!settings) { return; } + + const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); + download(`${document.getElementById('player-name').value}.yaml`, yamlText); + } + + generateGame(raceMode = false) { + const settings = this.#validateSettings(); + if (!settings) { return; } + + axios.post('/api/generate', { + weights: { player: JSON.stringify(settings) }, + presetData: { player: JSON.stringify(settings) }, + playerCount: 1, + spoiler: 3, + race: raceMode ? '1' : '0', + }).then((response) => { + window.location.href = response.data.url; + }).catch((error) => { + const userMessage = document.getElementById('user-message'); + userMessage.innerText = 'Something went wrong and your game could not be generated.'; + if (error.response.data.text) { + userMessage.innerText += ' ' + error.response.data.text; + } + userMessage.classList.add('visible'); + userMessage.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + console.error(error); + }); + } +} + +// Settings for an individual game. +class GameSettings { + // The WeightedSettings that contains this game's settings. Used to save + // settings after editing. + #allSettings; + + // The name of this game. + name; - const gamesWrapper = document.getElementById('games-wrapper'); - Object.keys(settingData.games).forEach((game) => { + // The data from the server describing the types of settings available for + // this game, as a JSON-safe blob. + get data() { + return this.#allSettings.data.games[this.name]; + } + + // The settings chosen by the user as they'd appear in the YAML file, stored + // to and retrieved from local storage. + get current() { + return this.#allSettings.current[this.name]; + } + + constructor(allSettings, name) { + this.#allSettings = allSettings; + this.name = name; + } + + // Builds and returns the settings UI for this game. + buildUI() { // Create game div, invisible by default const gameDiv = document.createElement('div'); - gameDiv.setAttribute('id', `${game}-div`); + gameDiv.setAttribute('id', `${this.name}-div`); gameDiv.classList.add('game-div'); gameDiv.classList.add('invisible'); const gameHeader = document.createElement('h2'); - gameHeader.innerText = game; + gameHeader.innerText = this.name; gameDiv.appendChild(gameHeader); const collapseButton = document.createElement('a'); @@ -137,29 +421,28 @@ const buildUI = (settingData) => { expandButton.classList.add('invisible'); gameDiv.appendChild(expandButton); - settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0))); - settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0))); + // Sort items and locations alphabetically. + this.data.gameItems.sort(); + this.data.gameLocations.sort(); - const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings, - settingData.games[game].gameItems, settingData.games[game].gameLocations); + const weightedSettingsDiv = this.#buildWeightedSettingsDiv(); gameDiv.appendChild(weightedSettingsDiv); - const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems); + const itemPoolDiv = this.#buildItemsDiv(); gameDiv.appendChild(itemPoolDiv); - const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations); + const hintsDiv = this.#buildHintsDiv(); gameDiv.appendChild(hintsDiv); - const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations); + const locationsDiv = this.#buildLocationsDiv(); gameDiv.appendChild(locationsDiv); - gamesWrapper.appendChild(gameDiv); - collapseButton.addEventListener('click', () => { collapseButton.classList.add('invisible'); weightedSettingsDiv.classList.add('invisible'); itemPoolDiv.classList.add('invisible'); hintsDiv.classList.add('invisible'); + locationsDiv.classList.add('invisible'); expandButton.classList.remove('invisible'); }); @@ -168,259 +451,148 @@ const buildUI = (settingData) => { weightedSettingsDiv.classList.remove('invisible'); itemPoolDiv.classList.remove('invisible'); hintsDiv.classList.remove('invisible'); + locationsDiv.classList.remove('invisible'); expandButton.classList.add('invisible'); }); - }); -}; -const buildGameChoice = (games) => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - const gameChoiceDiv = document.getElementById('game-choice'); - const h2 = document.createElement('h2'); - h2.innerText = 'Game Select'; - gameChoiceDiv.appendChild(h2); - - const gameSelectDescription = document.createElement('p'); - gameSelectDescription.classList.add('setting-description'); - gameSelectDescription.innerText = 'Choose which games you might be required to play.'; - gameChoiceDiv.appendChild(gameSelectDescription); - - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' + - 'to that section.' - gameChoiceDiv.appendChild(hintText); - - // Build the game choice table - const table = document.createElement('table'); - const tbody = document.createElement('tbody'); - - Object.keys(games).forEach((game) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - const span = document.createElement('span'); - span.innerText = game; - span.setAttribute('id', `${game}-game-option`) - tdLeft.appendChild(span); - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.setAttribute('data-type', 'weight'); - range.setAttribute('data-setting', 'game'); - range.setAttribute('data-option', game); - range.value = settings.game[game]; - range.addEventListener('change', (evt) => { - updateBaseSetting(evt); - updateVisibleGames(); // Show or hide games based on the new settings - }); - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `game-${game}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - tbody.appendChild(tr); - }); + return gameDiv; + } - table.appendChild(tbody); - gameChoiceDiv.appendChild(table); -}; + #buildWeightedSettingsDiv() { + const settingsWrapper = document.createElement('div'); + settingsWrapper.classList.add('settings-wrapper'); -const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const settingsWrapper = document.createElement('div'); - settingsWrapper.classList.add('settings-wrapper'); - - Object.keys(settings).forEach((settingName) => { - const setting = settings[settingName]; - const settingWrapper = document.createElement('div'); - settingWrapper.classList.add('setting-wrapper'); - - const settingNameHeader = document.createElement('h4'); - settingNameHeader.innerText = setting.displayName; - settingWrapper.appendChild(settingNameHeader); - - const settingDescription = document.createElement('p'); - settingDescription.classList.add('setting-description'); - settingDescription.innerText = setting.description.replace(/(\n)/g, ' '); - settingWrapper.appendChild(settingDescription); - - switch(setting.type){ - case 'select': - const optionTable = document.createElement('table'); - const tbody = document.createElement('tbody'); - - // Add a weight range for each option - setting.options.forEach((option) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option.name; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option.value); - range.setAttribute('data-type', setting.type); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][option.value]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option.value}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - tbody.appendChild(tr); - }); + Object.keys(this.data.gameSettings).forEach((settingName) => { + const setting = this.data.gameSettings[settingName]; + const settingWrapper = document.createElement('div'); + settingWrapper.classList.add('setting-wrapper'); - optionTable.appendChild(tbody); - settingWrapper.appendChild(optionTable); - break; + const settingNameHeader = document.createElement('h4'); + settingNameHeader.innerText = setting.displayName; + settingWrapper.appendChild(settingNameHeader); - case 'range': - case 'special_range': - const rangeTable = document.createElement('table'); - const rangeTbody = document.createElement('tbody'); + const settingDescription = document.createElement('p'); + settingDescription.classList.add('setting-description'); + settingDescription.innerText = setting.description.replace(/(\n)/g, ' '); + settingWrapper.appendChild(settingDescription); - if (((setting.max - setting.min) + 1) < 11) { - for (let i=setting.min; i <= setting.max; ++i) { + switch(setting.type){ + case 'select': + const optionTable = document.createElement('table'); + const tbody = document.createElement('tbody'); + + // Add a weight range for each option + setting.options.forEach((option) => { const tr = document.createElement('tr'); const tdLeft = document.createElement('td'); tdLeft.classList.add('td-left'); - tdLeft.innerText = i; + tdLeft.innerText = option.name; tr.appendChild(tdLeft); const tdMiddle = document.createElement('td'); tdMiddle.classList.add('td-middle'); const range = document.createElement('input'); range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${i}-range`); - range.setAttribute('data-game', game); + range.setAttribute('data-game', this.name); range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', i); + range.setAttribute('data-option', option.value); + range.setAttribute('data-type', setting.type); range.setAttribute('min', 0); range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][i] || 0; + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][option.value]; tdMiddle.appendChild(range); tr.appendChild(tdMiddle); const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${i}`) + tdRight.setAttribute('id', `${this.name}-${settingName}-${option.value}`); tdRight.classList.add('td-right'); tdRight.innerText = range.value; tr.appendChild(tdRight); - rangeTbody.appendChild(tr); - } - } else { - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' + - `below, then press the "Add" button to add a weight for it.
Minimum value: ${setting.min}
` + - `Maximum value: ${setting.max}`; - - if (setting.hasOwnProperty('value_names')) { - hintText.innerHTML += '

Certain values have special meaning:'; - Object.keys(setting.value_names).forEach((specialName) => { - hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; - }); - } - - settingWrapper.appendChild(hintText); - - const addOptionDiv = document.createElement('div'); - addOptionDiv.classList.add('add-option-div'); - const optionInput = document.createElement('input'); - optionInput.setAttribute('id', `${game}-${settingName}-option`); - optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`); - addOptionDiv.appendChild(optionInput); - const addOptionButton = document.createElement('button'); - addOptionButton.innerText = 'Add'; - addOptionDiv.appendChild(addOptionButton); - settingWrapper.appendChild(addOptionDiv); - optionInput.addEventListener('keydown', (evt) => { - if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } + tbody.appendChild(tr); }); - addOptionButton.addEventListener('click', () => { - const optionInput = document.getElementById(`${game}-${settingName}-option`); - let option = optionInput.value; - if (!option || !option.trim()) { return; } - option = parseInt(option, 10); - if ((option < setting.min) || (option > setting.max)) { return; } - optionInput.value = ''; - if (document.getElementById(`${game}-${settingName}-${option}-range`)) { return; } + optionTable.appendChild(tbody); + settingWrapper.appendChild(optionTable); + break; - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - tr.appendChild(tdLeft); + case 'range': + case 'special_range': + const rangeTable = document.createElement('table'); + const rangeTbody = document.createElement('tbody'); - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${option}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); + if (((setting.max - setting.min) + 1) < 11) { + for (let i=setting.min; i <= setting.max; ++i) { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = i; + tr.appendChild(tdLeft); - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${i}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', i); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][i] || 0; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = '❌'; - deleteButton.addEventListener('click', () => { - range.value = 0; - range.dispatchEvent(new Event('change')); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${i}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); - rangeTbody.appendChild(tr); + rangeTbody.appendChild(tr); + } + } else { + const hintText = document.createElement('p'); + hintText.classList.add('hint-text'); + hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' + + `below, then press the "Add" button to add a weight for it.
Minimum value: ${setting.min}
` + + `Maximum value: ${setting.max}`; + + if (setting.hasOwnProperty('value_names')) { + hintText.innerHTML += '

Certain values have special meaning:'; + Object.keys(setting.value_names).forEach((specialName) => { + hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; + }); + } - // Save new option to settings - range.dispatchEvent(new Event('change')); - }); + settingWrapper.appendChild(hintText); + + const addOptionDiv = document.createElement('div'); + addOptionDiv.classList.add('add-option-div'); + const optionInput = document.createElement('input'); + optionInput.setAttribute('id', `${this.name}-${settingName}-option`); + optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`); + addOptionDiv.appendChild(optionInput); + const addOptionButton = document.createElement('button'); + addOptionButton.innerText = 'Add'; + addOptionDiv.appendChild(addOptionButton); + settingWrapper.appendChild(addOptionDiv); + optionInput.addEventListener('keydown', (evt) => { + if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } + }); - Object.keys(currentSettings[game][settingName]).forEach((option) => { - // These options are statically generated below, and should always appear even if they are deleted - // from localStorage - if (['random-low', 'random', 'random-high'].includes(option)) { return; } + addOptionButton.addEventListener('click', () => { + const optionInput = document.getElementById(`${this.name}-${settingName}-option`); + let option = optionInput.value; + if (!option || !option.trim()) { return; } + option = parseInt(option, 10); + if ((option < setting.min) || (option > setting.max)) { return; } + optionInput.value = ''; + if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; } - const tr = document.createElement('tr'); + const tr = document.createElement('tr'); const tdLeft = document.createElement('td'); tdLeft.classList.add('td-left'); tdLeft.innerText = option; @@ -430,19 +602,19 @@ const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => { tdMiddle.classList.add('td-middle'); const range = document.createElement('input'); range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${option}-range`); - range.setAttribute('data-game', game); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); range.setAttribute('data-setting', settingName); range.setAttribute('data-option', option); range.setAttribute('min', 0); range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][parseInt(option, 10)]; + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][parseInt(option, 10)]; tdMiddle.appendChild(range); tr.appendChild(tdMiddle); const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option}`) + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) tdRight.classList.add('td-right'); tdRight.innerText = range.value; tr.appendChild(tdRight); @@ -454,731 +626,651 @@ const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => { deleteButton.innerText = '❌'; deleteButton.addEventListener('click', () => { range.value = 0; - const changeEvent = new Event('change'); - changeEvent.action = 'rangeDelete'; - range.dispatchEvent(changeEvent); + range.dispatchEvent(new Event('change')); rangeTbody.removeChild(tr); }); tdDelete.appendChild(deleteButton); tr.appendChild(tdDelete); rangeTbody.appendChild(tr); - }); - } - - ['random', 'random-low', 'random-high'].forEach((option) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - switch(option){ - case 'random': - tdLeft.innerText = 'Random'; - break; - case 'random-low': - tdLeft.innerText = "Random (Low)"; - break; - case 'random-high': - tdLeft.innerText = "Random (High)"; - break; - } - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${option}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][option]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - rangeTbody.appendChild(tr); - }); - - rangeTable.appendChild(rangeTbody); - settingWrapper.appendChild(rangeTable); - break; + // Save new option to settings + range.dispatchEvent(new Event('change')); + }); - case 'items-list': - const itemsList = document.createElement('div'); - itemsList.classList.add('simple-list'); - - Object.values(gameItems).forEach((item) => { - const itemRow = document.createElement('div'); - itemRow.classList.add('list-row'); - - const itemLabel = document.createElement('label'); - itemLabel.setAttribute('for', `${game}-${settingName}-${item}`) - - const itemCheckbox = document.createElement('input'); - itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`); - itemCheckbox.setAttribute('type', 'checkbox'); - itemCheckbox.setAttribute('data-game', game); - itemCheckbox.setAttribute('data-setting', settingName); - itemCheckbox.setAttribute('data-option', item.toString()); - itemCheckbox.addEventListener('change', updateListSetting); - if (currentSettings[game][settingName].includes(item)) { - itemCheckbox.setAttribute('checked', '1'); + Object.keys(this.current[settingName]).forEach((option) => { + // These options are statically generated below, and should always appear even if they are deleted + // from localStorage + if (['random-low', 'random', 'random-high'].includes(option)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + tr.appendChild(tdLeft); + + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][parseInt(option, 10)]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); + + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + const tdDelete = document.createElement('td'); + tdDelete.classList.add('td-delete'); + const deleteButton = document.createElement('span'); + deleteButton.classList.add('range-option-delete'); + deleteButton.innerText = '❌'; + deleteButton.addEventListener('click', () => { + range.value = 0; + const changeEvent = new Event('change'); + changeEvent.action = 'rangeDelete'; + range.dispatchEvent(changeEvent); + rangeTbody.removeChild(tr); + }); + tdDelete.appendChild(deleteButton); + tr.appendChild(tdDelete); + + rangeTbody.appendChild(tr); + }); } - const itemName = document.createElement('span'); - itemName.innerText = item.toString(); + ['random', 'random-low', 'random-high'].forEach((option) => { + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + switch(option){ + case 'random': + tdLeft.innerText = 'Random'; + break; + case 'random-low': + tdLeft.innerText = "Random (Low)"; + break; + case 'random-high': + tdLeft.innerText = "Random (High)"; + break; + } + tr.appendChild(tdLeft); - itemLabel.appendChild(itemCheckbox); - itemLabel.appendChild(itemName); + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); + range.setAttribute('data-game', this.name); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', option); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); + range.value = this.current[settingName][option]; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); - itemRow.appendChild(itemLabel); - itemsList.appendChild((itemRow)); - }); + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + rangeTbody.appendChild(tr); + }); - settingWrapper.appendChild(itemsList); - break; + rangeTable.appendChild(rangeTbody); + settingWrapper.appendChild(rangeTable); + break; + + case 'items-list': + const itemsList = document.createElement('div'); + itemsList.classList.add('simple-list'); + + Object.values(this.data.gameItems).forEach((item) => { + const itemRow = document.createElement('div'); + itemRow.classList.add('list-row'); + + const itemLabel = document.createElement('label'); + itemLabel.setAttribute('for', `${this.name}-${settingName}-${item}`) + + const itemCheckbox = document.createElement('input'); + itemCheckbox.setAttribute('id', `${this.name}-${settingName}-${item}`); + itemCheckbox.setAttribute('type', 'checkbox'); + itemCheckbox.setAttribute('data-game', this.name); + itemCheckbox.setAttribute('data-setting', settingName); + itemCheckbox.setAttribute('data-option', item.toString()); + itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + if (this.current[settingName].includes(item)) { + itemCheckbox.setAttribute('checked', '1'); + } - case 'locations-list': - const locationsList = document.createElement('div'); - locationsList.classList.add('simple-list'); - - Object.values(gameLocations).forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-${settingName}-${location}`) - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', settingName); - locationCheckbox.setAttribute('data-option', location.toString()); - locationCheckbox.addEventListener('change', updateListSetting); - if (currentSettings[game][settingName].includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } + const itemName = document.createElement('span'); + itemName.innerText = item.toString(); - const locationName = document.createElement('span'); - locationName.innerText = location.toString(); + itemLabel.appendChild(itemCheckbox); + itemLabel.appendChild(itemName); - locationLabel.appendChild(locationCheckbox); - locationLabel.appendChild(locationName); + itemRow.appendChild(itemLabel); + itemsList.appendChild((itemRow)); + }); - locationRow.appendChild(locationLabel); - locationsList.appendChild((locationRow)); - }); + settingWrapper.appendChild(itemsList); + break; + + case 'locations-list': + const locationsList = document.createElement('div'); + locationsList.classList.add('simple-list'); + + Object.values(this.data.gameLocations).forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-${settingName}-${location}`) + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('id', `${this.name}-${settingName}-${location}`); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', settingName); + locationCheckbox.setAttribute('data-option', location.toString()); + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + if (this.current[settingName].includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } - settingWrapper.appendChild(locationsList); - break; + const locationName = document.createElement('span'); + locationName.innerText = location.toString(); - case 'custom-list': - const customList = document.createElement('div'); - customList.classList.add('simple-list'); - - Object.values(settings[settingName].options).forEach((listItem) => { - const customListRow = document.createElement('div'); - customListRow.classList.add('list-row'); - - const customItemLabel = document.createElement('label'); - customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`) - - const customItemCheckbox = document.createElement('input'); - customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`); - customItemCheckbox.setAttribute('type', 'checkbox'); - customItemCheckbox.setAttribute('data-game', game); - customItemCheckbox.setAttribute('data-setting', settingName); - customItemCheckbox.setAttribute('data-option', listItem.toString()); - customItemCheckbox.addEventListener('change', updateListSetting); - if (currentSettings[game][settingName].includes(listItem)) { - customItemCheckbox.setAttribute('checked', '1'); - } + locationLabel.appendChild(locationCheckbox); + locationLabel.appendChild(locationName); - const customItemName = document.createElement('span'); - customItemName.innerText = listItem.toString(); + locationRow.appendChild(locationLabel); + locationsList.appendChild((locationRow)); + }); - customItemLabel.appendChild(customItemCheckbox); - customItemLabel.appendChild(customItemName); + settingWrapper.appendChild(locationsList); + break; + + case 'custom-list': + const customList = document.createElement('div'); + customList.classList.add('simple-list'); + + Object.values(this.data.gameSettings[settingName].options).forEach((listItem) => { + const customListRow = document.createElement('div'); + customListRow.classList.add('list-row'); + + const customItemLabel = document.createElement('label'); + customItemLabel.setAttribute('for', `${this.name}-${settingName}-${listItem}`) + + const customItemCheckbox = document.createElement('input'); + customItemCheckbox.setAttribute('id', `${this.name}-${settingName}-${listItem}`); + customItemCheckbox.setAttribute('type', 'checkbox'); + customItemCheckbox.setAttribute('data-game', this.name); + customItemCheckbox.setAttribute('data-setting', settingName); + customItemCheckbox.setAttribute('data-option', listItem.toString()); + customItemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + if (this.current[settingName].includes(listItem)) { + customItemCheckbox.setAttribute('checked', '1'); + } - customListRow.appendChild(customItemLabel); - customList.appendChild((customListRow)); - }); + const customItemName = document.createElement('span'); + customItemName.innerText = listItem.toString(); - settingWrapper.appendChild(customList); - break; + customItemLabel.appendChild(customItemCheckbox); + customItemLabel.appendChild(customItemName); - default: - console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`); - return; - } + customListRow.appendChild(customItemLabel); + customList.appendChild((customListRow)); + }); - settingsWrapper.appendChild(settingWrapper); - }); + settingWrapper.appendChild(customList); + break; - return settingsWrapper; -}; + default: + console.error(`Unknown setting type for ${this.name} setting ${settingName}: ${setting.type}`); + return; + } -const buildItemsDiv = (game, items) => { - // Sort alphabetical, in pace - items.sort(); - - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const itemsDiv = document.createElement('div'); - itemsDiv.classList.add('items-div'); - - const itemsDivHeader = document.createElement('h3'); - itemsDivHeader.innerText = 'Item Pool'; - itemsDiv.appendChild(itemsDivHeader); - - const itemsDescription = document.createElement('p'); - itemsDescription.classList.add('setting-description'); - itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' + - 'your seed or someone else\'s.'; - itemsDiv.appendChild(itemsDescription); - - const itemsHint = document.createElement('p'); - itemsHint.classList.add('hint-text'); - itemsHint.innerText = 'Drag and drop items from one box to another.'; - itemsDiv.appendChild(itemsHint); - - const itemsWrapper = document.createElement('div'); - itemsWrapper.classList.add('items-wrapper'); - - // Create container divs for each category - const availableItemsWrapper = document.createElement('div'); - availableItemsWrapper.classList.add('item-set-wrapper'); - availableItemsWrapper.innerText = 'Available Items'; - const availableItems = document.createElement('div'); - availableItems.classList.add('item-container'); - availableItems.setAttribute('id', `${game}-available_items`); - availableItems.addEventListener('dragover', itemDragoverHandler); - availableItems.addEventListener('drop', itemDropHandler); - - const startInventoryWrapper = document.createElement('div'); - startInventoryWrapper.classList.add('item-set-wrapper'); - startInventoryWrapper.innerText = 'Start Inventory'; - const startInventory = document.createElement('div'); - startInventory.classList.add('item-container'); - startInventory.setAttribute('id', `${game}-start_inventory`); - startInventory.setAttribute('data-setting', 'start_inventory'); - startInventory.addEventListener('dragover', itemDragoverHandler); - startInventory.addEventListener('drop', itemDropHandler); - - const localItemsWrapper = document.createElement('div'); - localItemsWrapper.classList.add('item-set-wrapper'); - localItemsWrapper.innerText = 'Local Items'; - const localItems = document.createElement('div'); - localItems.classList.add('item-container'); - localItems.setAttribute('id', `${game}-local_items`); - localItems.setAttribute('data-setting', 'local_items') - localItems.addEventListener('dragover', itemDragoverHandler); - localItems.addEventListener('drop', itemDropHandler); - - const nonLocalItemsWrapper = document.createElement('div'); - nonLocalItemsWrapper.classList.add('item-set-wrapper'); - nonLocalItemsWrapper.innerText = 'Non-Local Items'; - const nonLocalItems = document.createElement('div'); - nonLocalItems.classList.add('item-container'); - nonLocalItems.setAttribute('id', `${game}-non_local_items`); - nonLocalItems.setAttribute('data-setting', 'non_local_items'); - nonLocalItems.addEventListener('dragover', itemDragoverHandler); - nonLocalItems.addEventListener('drop', itemDropHandler); - - // Populate the divs - items.forEach((item) => { - if (Object.keys(currentSettings[game].start_inventory).includes(item)){ - const itemDiv = buildItemQtyDiv(game, item); - itemDiv.setAttribute('data-setting', 'start_inventory'); - startInventory.appendChild(itemDiv); - } else if (currentSettings[game].local_items.includes(item)) { - const itemDiv = buildItemDiv(game, item); - itemDiv.setAttribute('data-setting', 'local_items'); - localItems.appendChild(itemDiv); - } else if (currentSettings[game].non_local_items.includes(item)) { - const itemDiv = buildItemDiv(game, item); - itemDiv.setAttribute('data-setting', 'non_local_items'); - nonLocalItems.appendChild(itemDiv); - } else { - const itemDiv = buildItemDiv(game, item); - availableItems.appendChild(itemDiv); - } - }); + settingsWrapper.appendChild(settingWrapper); + }); - availableItemsWrapper.appendChild(availableItems); - startInventoryWrapper.appendChild(startInventory); - localItemsWrapper.appendChild(localItems); - nonLocalItemsWrapper.appendChild(nonLocalItems); - itemsWrapper.appendChild(availableItemsWrapper); - itemsWrapper.appendChild(startInventoryWrapper); - itemsWrapper.appendChild(localItemsWrapper); - itemsWrapper.appendChild(nonLocalItemsWrapper); - itemsDiv.appendChild(itemsWrapper); - return itemsDiv; -}; + return settingsWrapper; + } -const buildItemDiv = (game, item) => { - const itemDiv = document.createElement('div'); - itemDiv.classList.add('item-div'); - itemDiv.setAttribute('id', `${game}-${item}`); - itemDiv.setAttribute('data-game', game); - itemDiv.setAttribute('data-item', item); - itemDiv.setAttribute('draggable', 'true'); - itemDiv.innerText = item; - itemDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id')); - }); - return itemDiv; -}; + #buildItemsDiv() { + const itemsDiv = document.createElement('div'); + itemsDiv.classList.add('items-div'); + + const itemsDivHeader = document.createElement('h3'); + itemsDivHeader.innerText = 'Item Pool'; + itemsDiv.appendChild(itemsDivHeader); + + const itemsDescription = document.createElement('p'); + itemsDescription.classList.add('setting-description'); + itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' + + 'your seed or someone else\'s.'; + itemsDiv.appendChild(itemsDescription); + + const itemsHint = document.createElement('p'); + itemsHint.classList.add('hint-text'); + itemsHint.innerText = 'Drag and drop items from one box to another.'; + itemsDiv.appendChild(itemsHint); + + const itemsWrapper = document.createElement('div'); + itemsWrapper.classList.add('items-wrapper'); + + const itemDragoverHandler = (evt) => evt.preventDefault(); + const itemDropHandler = (evt) => this.#itemDropHandler(evt); + + // Create container divs for each category + const availableItemsWrapper = document.createElement('div'); + availableItemsWrapper.classList.add('item-set-wrapper'); + availableItemsWrapper.innerText = 'Available Items'; + const availableItems = document.createElement('div'); + availableItems.classList.add('item-container'); + availableItems.setAttribute('id', `${this.name}-available_items`); + availableItems.addEventListener('dragover', itemDragoverHandler); + availableItems.addEventListener('drop', itemDropHandler); + + const startInventoryWrapper = document.createElement('div'); + startInventoryWrapper.classList.add('item-set-wrapper'); + startInventoryWrapper.innerText = 'Start Inventory'; + const startInventory = document.createElement('div'); + startInventory.classList.add('item-container'); + startInventory.setAttribute('id', `${this.name}-start_inventory`); + startInventory.setAttribute('data-setting', 'start_inventory'); + startInventory.addEventListener('dragover', itemDragoverHandler); + startInventory.addEventListener('drop', itemDropHandler); + + const localItemsWrapper = document.createElement('div'); + localItemsWrapper.classList.add('item-set-wrapper'); + localItemsWrapper.innerText = 'Local Items'; + const localItems = document.createElement('div'); + localItems.classList.add('item-container'); + localItems.setAttribute('id', `${this.name}-local_items`); + localItems.setAttribute('data-setting', 'local_items') + localItems.addEventListener('dragover', itemDragoverHandler); + localItems.addEventListener('drop', itemDropHandler); + + const nonLocalItemsWrapper = document.createElement('div'); + nonLocalItemsWrapper.classList.add('item-set-wrapper'); + nonLocalItemsWrapper.innerText = 'Non-Local Items'; + const nonLocalItems = document.createElement('div'); + nonLocalItems.classList.add('item-container'); + nonLocalItems.setAttribute('id', `${this.name}-non_local_items`); + nonLocalItems.setAttribute('data-setting', 'non_local_items'); + nonLocalItems.addEventListener('dragover', itemDragoverHandler); + nonLocalItems.addEventListener('drop', itemDropHandler); + + // Populate the divs + this.data.gameItems.forEach((item) => { + if (Object.keys(this.current.start_inventory).includes(item)){ + const itemDiv = this.#buildItemQtyDiv(item); + itemDiv.setAttribute('data-setting', 'start_inventory'); + startInventory.appendChild(itemDiv); + } else if (this.current.local_items.includes(item)) { + const itemDiv = this.#buildItemDiv(item); + itemDiv.setAttribute('data-setting', 'local_items'); + localItems.appendChild(itemDiv); + } else if (this.current.non_local_items.includes(item)) { + const itemDiv = this.#buildItemDiv(item); + itemDiv.setAttribute('data-setting', 'non_local_items'); + nonLocalItems.appendChild(itemDiv); + } else { + const itemDiv = this.#buildItemDiv(item); + availableItems.appendChild(itemDiv); + } + }); -const buildItemQtyDiv = (game, item) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const itemQtyDiv = document.createElement('div'); - itemQtyDiv.classList.add('item-qty-div'); - itemQtyDiv.setAttribute('id', `${game}-${item}`); - itemQtyDiv.setAttribute('data-game', game); - itemQtyDiv.setAttribute('data-item', item); - itemQtyDiv.setAttribute('draggable', 'true'); - itemQtyDiv.innerText = item; - - const inputWrapper = document.createElement('div'); - inputWrapper.classList.add('item-qty-input-wrapper') - - const itemQty = document.createElement('input'); - itemQty.setAttribute('value', currentSettings[game].start_inventory.hasOwnProperty(item) ? - currentSettings[game].start_inventory[item] : '1'); - itemQty.setAttribute('data-game', game); - itemQty.setAttribute('data-setting', 'start_inventory'); - itemQty.setAttribute('data-option', item); - itemQty.setAttribute('maxlength', '3'); - itemQty.addEventListener('keyup', (evt) => { - evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value); - updateItemSetting(evt); - }); - inputWrapper.appendChild(itemQty); - itemQtyDiv.appendChild(inputWrapper); + availableItemsWrapper.appendChild(availableItems); + startInventoryWrapper.appendChild(startInventory); + localItemsWrapper.appendChild(localItems); + nonLocalItemsWrapper.appendChild(nonLocalItems); + itemsWrapper.appendChild(availableItemsWrapper); + itemsWrapper.appendChild(startInventoryWrapper); + itemsWrapper.appendChild(localItemsWrapper); + itemsWrapper.appendChild(nonLocalItemsWrapper); + itemsDiv.appendChild(itemsWrapper); + return itemsDiv; + } - itemQtyDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id')); - }); - return itemQtyDiv; -}; + #buildItemDiv(item) { + const itemDiv = document.createElement('div'); + itemDiv.classList.add('item-div'); + itemDiv.setAttribute('id', `${this.name}-${item}`); + itemDiv.setAttribute('data-game', this.name); + itemDiv.setAttribute('data-item', item); + itemDiv.setAttribute('draggable', 'true'); + itemDiv.innerText = item; + itemDiv.addEventListener('dragstart', (evt) => { + evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id')); + }); + return itemDiv; + } -const itemDragoverHandler = (evt) => { - evt.preventDefault(); -}; + #buildItemQtyDiv(item) { + const itemQtyDiv = document.createElement('div'); + itemQtyDiv.classList.add('item-qty-div'); + itemQtyDiv.setAttribute('id', `${this.name}-${item}`); + itemQtyDiv.setAttribute('data-game', this.name); + itemQtyDiv.setAttribute('data-item', item); + itemQtyDiv.setAttribute('draggable', 'true'); + itemQtyDiv.innerText = item; + + const inputWrapper = document.createElement('div'); + inputWrapper.classList.add('item-qty-input-wrapper') + + const itemQty = document.createElement('input'); + itemQty.setAttribute('value', this.current.start_inventory.hasOwnProperty(item) ? + this.current.start_inventory[item] : '1'); + itemQty.setAttribute('data-game', this.name); + itemQty.setAttribute('data-setting', 'start_inventory'); + itemQty.setAttribute('data-option', item); + itemQty.setAttribute('maxlength', '3'); + itemQty.addEventListener('keyup', (evt) => { + evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value); + this.#updateItemSetting(evt); + }); + inputWrapper.appendChild(itemQty); + itemQtyDiv.appendChild(inputWrapper); + + itemQtyDiv.addEventListener('dragstart', (evt) => { + evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id')); + }); + return itemQtyDiv; + } -const itemDropHandler = (evt) => { - evt.preventDefault(); - const sourceId = evt.dataTransfer.getData('text/plain'); - const sourceDiv = document.getElementById(sourceId); + #itemDropHandler(evt) { + evt.preventDefault(); + const sourceId = evt.dataTransfer.getData('text/plain'); + const sourceDiv = document.getElementById(sourceId); - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const game = sourceDiv.getAttribute('data-game'); - const item = sourceDiv.getAttribute('data-item'); + const item = sourceDiv.getAttribute('data-item'); - const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null; - const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null; + const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null; + const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null; - const itemDiv = newSetting === 'start_inventory' ? buildItemQtyDiv(game, item) : buildItemDiv(game, item); + const itemDiv = newSetting === 'start_inventory' ? this.#buildItemQtyDiv(item) : this.#buildItemDiv(item); - if (oldSetting) { - if (oldSetting === 'start_inventory') { - if (currentSettings[game][oldSetting].hasOwnProperty(item)) { - delete currentSettings[game][oldSetting][item]; - } - } else { - if (currentSettings[game][oldSetting].includes(item)) { - currentSettings[game][oldSetting].splice(currentSettings[game][oldSetting].indexOf(item), 1); + if (oldSetting) { + if (oldSetting === 'start_inventory') { + if (this.current[oldSetting].hasOwnProperty(item)) { + delete this.current[oldSetting][item]; + } + } else { + if (this.current[oldSetting].includes(item)) { + this.current[oldSetting].splice(this.current[oldSetting].indexOf(item), 1); + } } } - } - if (newSetting) { - itemDiv.setAttribute('data-setting', newSetting); - document.getElementById(`${game}-${newSetting}`).appendChild(itemDiv); - if (newSetting === 'start_inventory') { - currentSettings[game][newSetting][item] = 1; - } else { - if (!currentSettings[game][newSetting].includes(item)){ - currentSettings[game][newSetting].push(item); + if (newSetting) { + itemDiv.setAttribute('data-setting', newSetting); + document.getElementById(`${this.name}-${newSetting}`).appendChild(itemDiv); + if (newSetting === 'start_inventory') { + this.current[newSetting][item] = 1; + } else { + if (!this.current[newSetting].includes(item)){ + this.current[newSetting].push(item); + } } + } else { + // No setting was assigned, this item has been removed from the settings + document.getElementById(`${this.name}-available_items`).appendChild(itemDiv); } - } else { - // No setting was assigned, this item has been removed from the settings - document.getElementById(`${game}-available_items`).appendChild(itemDiv); - } - // Remove the source drag object - sourceDiv.parentElement.removeChild(sourceDiv); + // Remove the source drag object + sourceDiv.parentElement.removeChild(sourceDiv); - // Save the updated settings - localStorage.setItem('weighted-settings', JSON.stringify(currentSettings)); -}; + // Save the updated settings + this.save(); + } -const buildHintsDiv = (game, items, locations) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - - // Sort alphabetical, in place - items.sort(); - locations.sort(); - - const hintsDiv = document.createElement('div'); - hintsDiv.classList.add('hints-div'); - const hintsHeader = document.createElement('h3'); - hintsHeader.innerText = 'Item & Location Hints'; - hintsDiv.appendChild(hintsHeader); - const hintsDescription = document.createElement('p'); - hintsDescription.classList.add('setting-description'); - hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' + - ' items are, or what those locations contain.'; - hintsDiv.appendChild(hintsDescription); - - const itemHintsContainer = document.createElement('div'); - itemHintsContainer.classList.add('hints-container'); - - // Item Hints - const itemHintsWrapper = document.createElement('div'); - itemHintsWrapper.classList.add('hints-wrapper'); - itemHintsWrapper.innerText = 'Starting Item Hints'; - - const itemHintsDiv = document.createElement('div'); - itemHintsDiv.classList.add('simple-list'); - items.forEach((item) => { - const itemRow = document.createElement('div'); - itemRow.classList.add('list-row'); - - const itemLabel = document.createElement('label'); - itemLabel.setAttribute('for', `${game}-start_hints-${item}`); - - const itemCheckbox = document.createElement('input'); - itemCheckbox.setAttribute('type', 'checkbox'); - itemCheckbox.setAttribute('id', `${game}-start_hints-${item}`); - itemCheckbox.setAttribute('data-game', game); - itemCheckbox.setAttribute('data-setting', 'start_hints'); - itemCheckbox.setAttribute('data-option', item); - if (currentSettings[game].start_hints.includes(item)) { - itemCheckbox.setAttribute('checked', 'true'); - } - itemCheckbox.addEventListener('change', updateListSetting); - itemLabel.appendChild(itemCheckbox); + #buildHintsDiv() { + const hintsDiv = document.createElement('div'); + hintsDiv.classList.add('hints-div'); + const hintsHeader = document.createElement('h3'); + hintsHeader.innerText = 'Item & Location Hints'; + hintsDiv.appendChild(hintsHeader); + const hintsDescription = document.createElement('p'); + hintsDescription.classList.add('setting-description'); + hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' + + ' items are, or what those locations contain.'; + hintsDiv.appendChild(hintsDescription); + + const itemHintsContainer = document.createElement('div'); + itemHintsContainer.classList.add('hints-container'); + + // Item Hints + const itemHintsWrapper = document.createElement('div'); + itemHintsWrapper.classList.add('hints-wrapper'); + itemHintsWrapper.innerText = 'Starting Item Hints'; + + const itemHintsDiv = document.createElement('div'); + itemHintsDiv.classList.add('simple-list'); + this.data.gameItems.forEach((item) => { + const itemRow = document.createElement('div'); + itemRow.classList.add('list-row'); + + const itemLabel = document.createElement('label'); + itemLabel.setAttribute('for', `${this.name}-start_hints-${item}`); + + const itemCheckbox = document.createElement('input'); + itemCheckbox.setAttribute('type', 'checkbox'); + itemCheckbox.setAttribute('id', `${this.name}-start_hints-${item}`); + itemCheckbox.setAttribute('data-game', this.name); + itemCheckbox.setAttribute('data-setting', 'start_hints'); + itemCheckbox.setAttribute('data-option', item); + if (this.current.start_hints.includes(item)) { + itemCheckbox.setAttribute('checked', 'true'); + } + itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + itemLabel.appendChild(itemCheckbox); - const itemName = document.createElement('span'); - itemName.innerText = item; - itemLabel.appendChild(itemName); + const itemName = document.createElement('span'); + itemName.innerText = item; + itemLabel.appendChild(itemName); - itemRow.appendChild(itemLabel); - itemHintsDiv.appendChild(itemRow); - }); + itemRow.appendChild(itemLabel); + itemHintsDiv.appendChild(itemRow); + }); - itemHintsWrapper.appendChild(itemHintsDiv); - itemHintsContainer.appendChild(itemHintsWrapper); - - // Starting Location Hints - const locationHintsWrapper = document.createElement('div'); - locationHintsWrapper.classList.add('hints-wrapper'); - locationHintsWrapper.innerText = 'Starting Location Hints'; - - const locationHintsDiv = document.createElement('div'); - locationHintsDiv.classList.add('simple-list'); - locations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${game}-start_location_hints-${location}`); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', 'start_location_hints'); - locationCheckbox.setAttribute('data-option', location); - if (currentSettings[game].start_location_hints.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', updateListSetting); - locationLabel.appendChild(locationCheckbox); + itemHintsWrapper.appendChild(itemHintsDiv); + itemHintsContainer.appendChild(itemHintsWrapper); + + // Starting Location Hints + const locationHintsWrapper = document.createElement('div'); + locationHintsWrapper.classList.add('hints-wrapper'); + locationHintsWrapper.innerText = 'Starting Location Hints'; + + const locationHintsDiv = document.createElement('div'); + locationHintsDiv.classList.add('simple-list'); + this.data.gameLocations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-start_location_hints-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${this.name}-start_location_hints-${location}`); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', 'start_location_hints'); + locationCheckbox.setAttribute('data-option', location); + if (this.current.start_location_hints.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + locationLabel.appendChild(locationCheckbox); - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); - locationRow.appendChild(locationLabel); - locationHintsDiv.appendChild(locationRow); - }); + locationRow.appendChild(locationLabel); + locationHintsDiv.appendChild(locationRow); + }); - locationHintsWrapper.appendChild(locationHintsDiv); - itemHintsContainer.appendChild(locationHintsWrapper); + locationHintsWrapper.appendChild(locationHintsDiv); + itemHintsContainer.appendChild(locationHintsWrapper); - hintsDiv.appendChild(itemHintsContainer); - return hintsDiv; -}; + hintsDiv.appendChild(itemHintsContainer); + return hintsDiv; + } -const buildLocationsDiv = (game, locations) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - locations.sort(); // Sort alphabetical, in-place - - const locationsDiv = document.createElement('div'); - locationsDiv.classList.add('locations-div'); - const locationsHeader = document.createElement('h3'); - locationsHeader.innerText = 'Priority & Exclusion Locations'; - locationsDiv.appendChild(locationsHeader); - const locationsDescription = document.createElement('p'); - locationsDescription.classList.add('setting-description'); - locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' + - 'excluded locations will not contain progression or useful items.'; - locationsDiv.appendChild(locationsDescription); - - const locationsContainer = document.createElement('div'); - locationsContainer.classList.add('locations-container'); - - // Priority Locations - const priorityLocationsWrapper = document.createElement('div'); - priorityLocationsWrapper.classList.add('locations-wrapper'); - priorityLocationsWrapper.innerText = 'Priority Locations'; - - const priorityLocationsDiv = document.createElement('div'); - priorityLocationsDiv.classList.add('simple-list'); - locations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-priority_locations-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', 'priority_locations'); - locationCheckbox.setAttribute('data-option', location); - if (currentSettings[game].priority_locations.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', updateListSetting); - locationLabel.appendChild(locationCheckbox); + #buildLocationsDiv() { + const locationsDiv = document.createElement('div'); + locationsDiv.classList.add('locations-div'); + const locationsHeader = document.createElement('h3'); + locationsHeader.innerText = 'Priority & Exclusion Locations'; + locationsDiv.appendChild(locationsHeader); + const locationsDescription = document.createElement('p'); + locationsDescription.classList.add('setting-description'); + locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' + + 'excluded locations will not contain progression or useful items.'; + locationsDiv.appendChild(locationsDescription); + + const locationsContainer = document.createElement('div'); + locationsContainer.classList.add('locations-container'); + + // Priority Locations + const priorityLocationsWrapper = document.createElement('div'); + priorityLocationsWrapper.classList.add('locations-wrapper'); + priorityLocationsWrapper.innerText = 'Priority Locations'; + + const priorityLocationsDiv = document.createElement('div'); + priorityLocationsDiv.classList.add('simple-list'); + this.data.gameLocations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-priority_locations-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${this.name}-priority_locations-${location}`); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', 'priority_locations'); + locationCheckbox.setAttribute('data-option', location); + if (this.current.priority_locations.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + locationLabel.appendChild(locationCheckbox); - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); - locationRow.appendChild(locationLabel); - priorityLocationsDiv.appendChild(locationRow); - }); + locationRow.appendChild(locationLabel); + priorityLocationsDiv.appendChild(locationRow); + }); - priorityLocationsWrapper.appendChild(priorityLocationsDiv); - locationsContainer.appendChild(priorityLocationsWrapper); - - // Exclude Locations - const excludeLocationsWrapper = document.createElement('div'); - excludeLocationsWrapper.classList.add('locations-wrapper'); - excludeLocationsWrapper.innerText = 'Exclude Locations'; - - const excludeLocationsDiv = document.createElement('div'); - excludeLocationsDiv.classList.add('simple-list'); - locations.forEach((location) => { - const locationRow = document.createElement('div'); - locationRow.classList.add('list-row'); - - const locationLabel = document.createElement('label'); - locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`); - - const locationCheckbox = document.createElement('input'); - locationCheckbox.setAttribute('type', 'checkbox'); - locationCheckbox.setAttribute('id', `${game}-exclude_locations-${location}`); - locationCheckbox.setAttribute('data-game', game); - locationCheckbox.setAttribute('data-setting', 'exclude_locations'); - locationCheckbox.setAttribute('data-option', location); - if (currentSettings[game].exclude_locations.includes(location)) { - locationCheckbox.setAttribute('checked', '1'); - } - locationCheckbox.addEventListener('change', updateListSetting); - locationLabel.appendChild(locationCheckbox); + priorityLocationsWrapper.appendChild(priorityLocationsDiv); + locationsContainer.appendChild(priorityLocationsWrapper); + + // Exclude Locations + const excludeLocationsWrapper = document.createElement('div'); + excludeLocationsWrapper.classList.add('locations-wrapper'); + excludeLocationsWrapper.innerText = 'Exclude Locations'; + + const excludeLocationsDiv = document.createElement('div'); + excludeLocationsDiv.classList.add('simple-list'); + this.data.gameLocations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${this.name}-exclude_locations-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${this.name}-exclude_locations-${location}`); + locationCheckbox.setAttribute('data-game', this.name); + locationCheckbox.setAttribute('data-setting', 'exclude_locations'); + locationCheckbox.setAttribute('data-option', location); + if (this.current.exclude_locations.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + locationLabel.appendChild(locationCheckbox); - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); - locationRow.appendChild(locationLabel); - excludeLocationsDiv.appendChild(locationRow); - }); + locationRow.appendChild(locationLabel); + excludeLocationsDiv.appendChild(locationRow); + }); - excludeLocationsWrapper.appendChild(excludeLocationsDiv); - locationsContainer.appendChild(excludeLocationsWrapper); + excludeLocationsWrapper.appendChild(excludeLocationsDiv); + locationsContainer.appendChild(excludeLocationsWrapper); - locationsDiv.appendChild(locationsContainer); - return locationsDiv; -}; + locationsDiv.appendChild(locationsContainer); + return locationsDiv; + } -const updateVisibleGames = () => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - Object.keys(settings.game).forEach((game) => { - const gameDiv = document.getElementById(`${game}-div`); - const gameOption = document.getElementById(`${game}-game-option`); - if (parseInt(settings.game[game], 10) > 0) { - gameDiv.classList.remove('invisible'); - gameOption.classList.add('jump-link'); - gameOption.addEventListener('click', () => { - const gameDiv = document.getElementById(`${game}-div`); - if (gameDiv.classList.contains('invisible')) { return; } - gameDiv.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - }); + #updateRangeSetting(evt) { + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); + document.getElementById(`${this.name}-${setting}-${option}`).innerText = evt.target.value; + if (evt.action && evt.action === 'rangeDelete') { + delete this.current[setting][option]; } else { - gameDiv.classList.add('invisible'); - gameOption.classList.remove('jump-link'); - + this.current[setting][option] = parseInt(evt.target.value, 10); } - }); -}; - -const updateBaseSetting = (event) => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - const setting = event.target.getAttribute('data-setting'); - const option = event.target.getAttribute('data-option'); - const type = event.target.getAttribute('data-type'); - - switch(type){ - case 'weight': - settings[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); - document.getElementById(`${setting}-${option}`).innerText = event.target.value; - break; - case 'data': - settings[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); - break; - } - - localStorage.setItem('weighted-settings', JSON.stringify(settings)); -}; - -const updateRangeSetting = (evt) => { - const options = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; - if (evt.action && evt.action === 'rangeDelete') { - delete options[game][setting][option]; - } else { - options[game][setting][option] = parseInt(evt.target.value, 10); + this.save(); } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; - -const updateListSetting = (evt) => { - const options = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - - if (evt.target.checked) { - // If the option is to be enabled and it is already enabled, do nothing - if (options[game][setting].includes(option)) { return; } - options[game][setting].push(option); - } else { - // If the option is to be disabled and it is already disabled, do nothing - if (!options[game][setting].includes(option)) { return; } + #updateListSetting(evt) { + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); - options[game][setting].splice(options[game][setting].indexOf(option), 1); - } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; + if (evt.target.checked) { + // If the option is to be enabled and it is already enabled, do nothing + if (this.current[setting].includes(option)) { return; } -const updateItemSetting = (evt) => { - const options = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - if (setting === 'start_inventory') { - options[game][setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0; - } else { - options[game][setting][option] = isNaN(evt.target.value) ? - evt.target.value : parseInt(evt.target.value, 10); - } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; + this.current[setting].push(option); + } else { + // If the option is to be disabled and it is already disabled, do nothing + if (!this.current[setting].includes(option)) { return; } -const validateSettings = () => { - const settings = JSON.parse(localStorage.getItem('weighted-settings')); - const userMessage = document.getElementById('user-message'); - let errorMessage = null; - - // User must choose a name for their file - if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') { - userMessage.innerText = 'You forgot to set your player name at the top of the page!'; - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return; + this.current[setting].splice(this.current[setting].indexOf(option), 1); + } + this.save(); } - // Clean up the settings output - Object.keys(settings.game).forEach((game) => { - // Remove any disabled games - if (settings.game[game] === 0) { - delete settings.game[game]; - delete settings[game]; - return; + #updateItemSetting(evt) { + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); + if (setting === 'start_inventory') { + this.current[setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0; + } else { + this.current[setting][option] = isNaN(evt.target.value) ? + evt.target.value : parseInt(evt.target.value, 10); } - - // Remove any disabled options - Object.keys(settings[game]).forEach((setting) => { - Object.keys(settings[game][setting]).forEach((option) => { - if (settings[game][setting][option] === 0) { - delete settings[game][setting][option]; - } - }); - - if ( - Object.keys(settings[game][setting]).length === 0 && - !Array.isArray(settings[game][setting]) && - setting !== 'start_inventory' - ) { - errorMessage = `${game} // ${setting} has no values above zero!`; - } - }); - }); - - if (Object.keys(settings.game).length === 0) { - errorMessage = 'You have not chosen a game to play!'; + this.save(); } - // If an error occurred, alert the user and do not export the file - if (errorMessage) { - userMessage.innerText = errorMessage; - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return; + // Saves the current settings to local storage. + save() { + this.#allSettings.save(); } - - // If no error occurred, hide the user message if it is visible - userMessage.classList.remove('visible'); - return settings; -}; - -const exportSettings = () => { - const settings = validateSettings(); - if (!settings) { return; } - - const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); - download(`${document.getElementById('player-name').value}.yaml`, yamlText); -}; +} /** Create an anchor and trigger a download of a text file. */ const download = (filename, text) => { @@ -1190,30 +1282,3 @@ const download = (filename, text) => { downloadLink.click(); document.body.removeChild(downloadLink); }; - -const generateGame = (raceMode = false) => { - const settings = validateSettings(); - if (!settings) { return; } - - axios.post('/api/generate', { - weights: { player: JSON.stringify(settings) }, - presetData: { player: JSON.stringify(settings) }, - playerCount: 1, - spoiler: 3, - race: raceMode ? '1' : '0', - }).then((response) => { - window.location.href = response.data.url; - }).catch((error) => { - const userMessage = document.getElementById('user-message'); - userMessage.innerText = 'Something went wrong and your game could not be generated.'; - if (error.response.data.text) { - userMessage.innerText += ' ' + error.response.data.text; - } - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - console.error(error); - }); -}; diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png new file mode 100644 index 000000000000..8fb366b93ff0 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png differ diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png new file mode 100644 index 000000000000..336dc5f77af2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png differ diff --git a/WebHostLib/static/static/icons/sc2/advanceballistics.png b/WebHostLib/static/static/icons/sc2/advanceballistics.png new file mode 100644 index 000000000000..1bf7df9fb74c Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/advanceballistics.png differ diff --git a/WebHostLib/static/static/icons/sc2/autoturretblackops.png b/WebHostLib/static/static/icons/sc2/autoturretblackops.png new file mode 100644 index 000000000000..552707831a00 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/autoturretblackops.png differ diff --git a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png new file mode 100644 index 000000000000..e7ebf4031619 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png differ diff --git a/WebHostLib/static/static/icons/sc2/burstcapacitors.png b/WebHostLib/static/static/icons/sc2/burstcapacitors.png new file mode 100644 index 000000000000..3af9b20a1698 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/burstcapacitors.png differ diff --git a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png new file mode 100644 index 000000000000..d1c0c6c9a010 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png differ diff --git a/WebHostLib/static/static/icons/sc2/cyclone.png b/WebHostLib/static/static/icons/sc2/cyclone.png new file mode 100644 index 000000000000..d2016116ea3b Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclone.png differ diff --git a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png new file mode 100644 index 000000000000..351be570d11b Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png differ diff --git a/WebHostLib/static/static/icons/sc2/drillingclaws.png b/WebHostLib/static/static/icons/sc2/drillingclaws.png new file mode 100644 index 000000000000..2b067a6e44d4 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/drillingclaws.png differ diff --git a/WebHostLib/static/static/icons/sc2/emergencythrusters.png b/WebHostLib/static/static/icons/sc2/emergencythrusters.png new file mode 100644 index 000000000000..159fba37c903 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/emergencythrusters.png differ diff --git a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png new file mode 100644 index 000000000000..56bfd98c924c Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png new file mode 100644 index 000000000000..40a5991ebb80 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png differ diff --git a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png new file mode 100644 index 000000000000..375325845876 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png differ diff --git a/WebHostLib/static/static/icons/sc2/hyperfluxor.png b/WebHostLib/static/static/icons/sc2/hyperfluxor.png new file mode 100644 index 000000000000..cdd95bb515be Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperfluxor.png differ diff --git a/WebHostLib/static/static/icons/sc2/impalerrounds.png b/WebHostLib/static/static/icons/sc2/impalerrounds.png new file mode 100644 index 000000000000..b00e0c475827 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/impalerrounds.png differ diff --git a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png new file mode 100644 index 000000000000..8a48e38e874d Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png differ diff --git a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png new file mode 100644 index 000000000000..f19dad952bb5 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/interferencematrix.png b/WebHostLib/static/static/icons/sc2/interferencematrix.png new file mode 100644 index 000000000000..ced928aa57a9 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/interferencematrix.png differ diff --git a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png new file mode 100644 index 000000000000..e97f3db0d29a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png differ diff --git a/WebHostLib/static/static/icons/sc2/jotunboosters.png b/WebHostLib/static/static/icons/sc2/jotunboosters.png new file mode 100644 index 000000000000..25720306e5c2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jotunboosters.png differ diff --git a/WebHostLib/static/static/icons/sc2/jumpjets.png b/WebHostLib/static/static/icons/sc2/jumpjets.png new file mode 100644 index 000000000000..dfdfef4052ca Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jumpjets.png differ diff --git a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png new file mode 100644 index 000000000000..c57899b270ff Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png differ diff --git a/WebHostLib/static/static/icons/sc2/liberator.png b/WebHostLib/static/static/icons/sc2/liberator.png new file mode 100644 index 000000000000..31507be5fe68 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/liberator.png differ diff --git a/WebHostLib/static/static/icons/sc2/lockdown.png b/WebHostLib/static/static/icons/sc2/lockdown.png new file mode 100644 index 000000000000..a2e7f5dc3e3f Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lockdown.png differ diff --git a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png new file mode 100644 index 000000000000..0272b4b73892 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png differ diff --git a/WebHostLib/static/static/icons/sc2/magrailmunitions.png b/WebHostLib/static/static/icons/sc2/magrailmunitions.png new file mode 100644 index 000000000000..ec303498ccdb Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magrailmunitions.png differ diff --git a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png new file mode 100644 index 000000000000..1c7ce9d6ab1a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png differ diff --git a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png new file mode 100644 index 000000000000..04d68d35dc46 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png differ diff --git a/WebHostLib/static/static/icons/sc2/opticalflare.png b/WebHostLib/static/static/icons/sc2/opticalflare.png new file mode 100644 index 000000000000..f888fd518b99 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/opticalflare.png differ diff --git a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png new file mode 100644 index 000000000000..dcf5fd72da86 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png differ diff --git a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png new file mode 100644 index 000000000000..b9f2f055c265 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png differ diff --git a/WebHostLib/static/static/icons/sc2/restoration.png b/WebHostLib/static/static/icons/sc2/restoration.png new file mode 100644 index 000000000000..f5c94e1aeefd Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/restoration.png differ diff --git a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png new file mode 100644 index 000000000000..f68e82039765 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png differ diff --git a/WebHostLib/static/static/icons/sc2/shreddermissile.png b/WebHostLib/static/static/icons/sc2/shreddermissile.png new file mode 100644 index 000000000000..40899095fe3a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/shreddermissile.png differ diff --git a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png new file mode 100644 index 000000000000..1b9f8cf06097 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png differ diff --git a/WebHostLib/static/static/icons/sc2/siegetankrange.png b/WebHostLib/static/static/icons/sc2/siegetankrange.png new file mode 100644 index 000000000000..5aef00a656c9 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetankrange.png differ diff --git a/WebHostLib/static/static/icons/sc2/specialordance.png b/WebHostLib/static/static/icons/sc2/specialordance.png new file mode 100644 index 000000000000..4f7410d7ca9e Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/specialordance.png differ diff --git a/WebHostLib/static/static/icons/sc2/spidermine.png b/WebHostLib/static/static/icons/sc2/spidermine.png new file mode 100644 index 000000000000..bb39cf0bf8ce Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/spidermine.png differ diff --git a/WebHostLib/static/static/icons/sc2/staticempblast.png b/WebHostLib/static/static/icons/sc2/staticempblast.png new file mode 100644 index 000000000000..38f361510775 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/staticempblast.png differ diff --git a/WebHostLib/static/static/icons/sc2/superstimpack.png b/WebHostLib/static/static/icons/sc2/superstimpack.png new file mode 100644 index 000000000000..0fba8ce5749a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/superstimpack.png differ diff --git a/WebHostLib/static/static/icons/sc2/targetingoptics.png b/WebHostLib/static/static/icons/sc2/targetingoptics.png new file mode 100644 index 000000000000..057a40f08e30 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/targetingoptics.png differ diff --git a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png new file mode 100644 index 000000000000..44d1bb9541fb Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png differ diff --git a/WebHostLib/static/static/icons/sc2/terran-emp-color.png b/WebHostLib/static/static/icons/sc2/terran-emp-color.png new file mode 100644 index 000000000000..972b828c75e2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-emp-color.png differ diff --git a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png new file mode 100644 index 000000000000..9d5982655183 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png differ diff --git a/WebHostLib/static/static/icons/sc2/thorsiegemode.png b/WebHostLib/static/static/icons/sc2/thorsiegemode.png new file mode 100644 index 000000000000..a298fb57de5a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/thorsiegemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/transformationservos.png b/WebHostLib/static/static/icons/sc2/transformationservos.png new file mode 100644 index 000000000000..f7f0524ac15c Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/transformationservos.png differ diff --git a/WebHostLib/static/static/icons/sc2/valkyrie.png b/WebHostLib/static/static/icons/sc2/valkyrie.png new file mode 100644 index 000000000000..9cbf339b10db Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/valkyrie.png differ diff --git a/WebHostLib/static/static/icons/sc2/warpjump.png b/WebHostLib/static/static/icons/sc2/warpjump.png new file mode 100644 index 000000000000..ff0a7b1af4aa Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/warpjump.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png new file mode 100644 index 000000000000..8f5e09c6a593 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png new file mode 100644 index 000000000000..7097db05e6c0 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine.png b/WebHostLib/static/static/icons/sc2/widowmine.png new file mode 100644 index 000000000000..802c49a83d88 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowminehidden.png b/WebHostLib/static/static/icons/sc2/widowminehidden.png new file mode 100644 index 000000000000..e568742e8a50 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowminehidden.png differ diff --git a/WebHostLib/static/styles/landing.css b/WebHostLib/static/styles/landing.css index 202c43badd5f..96975553c142 100644 --- a/WebHostLib/static/styles/landing.css +++ b/WebHostLib/static/styles/landing.css @@ -235,9 +235,6 @@ html{ line-height: 30px; } -#landing .variable{ - color: #ffff00; -} .landing-deco{ position: absolute; diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css index b68668ecf60e..a7d8bd28c4f8 100644 --- a/WebHostLib/static/styles/sc2wolTracker.css +++ b/WebHostLib/static/styles/sc2wolTracker.css @@ -9,7 +9,7 @@ border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 3px 3px 10px; - width: 500px; + width: 710px; background-color: #525494; } @@ -34,10 +34,12 @@ max-height: 40px; border: 1px solid #000000; filter: grayscale(100%) contrast(75%) brightness(20%); + background-color: black; } #inventory-table img.acquired{ filter: none; + background-color: black; } #inventory-table div.counted-item { @@ -52,7 +54,7 @@ } #location-table{ - width: 500px; + width: 710px; border-left: 2px solid #000000; border-right: 2px solid #000000; border-bottom: 2px solid #000000; diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css index f86ab581ca47..7396daa95404 100644 --- a/WebHostLib/static/styles/supportedGames.css +++ b/WebHostLib/static/styles/supportedGames.css @@ -18,6 +18,22 @@ margin-bottom: 2px; } +#games .collapse-toggle{ + cursor: pointer; +} + +#games h2 .collapse-arrow{ + font-size: 20px; + display: inline-block; /* make vertical-align work */ + padding-bottom: 9px; + vertical-align: middle; + padding-right: 8px; +} + +#games p.collapsed{ + display: none; +} + #games a{ font-size: 16px; } @@ -31,3 +47,13 @@ line-height: 25px; margin-bottom: 7px; } + +#games .page-controls{ + display: flex; + flex-direction: row; + margin-top: 0.25rem; +} + +#games .page-controls button{ + margin-left: 0.5rem; +} diff --git a/WebHostLib/templates/check.html b/WebHostLib/templates/check.html index 04b51340b513..8a3da7db472a 100644 --- a/WebHostLib/templates/check.html +++ b/WebHostLib/templates/check.html @@ -17,9 +17,9 @@

Upload Yaml

- +
- +
diff --git a/WebHostLib/templates/checkResult.html b/WebHostLib/templates/checkResult.html index c245d7381a4c..75ae7479f5ff 100644 --- a/WebHostLib/templates/checkResult.html +++ b/WebHostLib/templates/checkResult.html @@ -28,6 +28,10 @@

Verification Results

{% endfor %} + {% if combined_yaml %} +

Combined File Download

+

Download

+ {% endif %} {% endblock %} diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index dd25a908049d..33f8dbc09e6c 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -203,10 +203,10 @@

Generate Game{% if race %} (Race Mode){% endif %}

- +
- + diff --git a/WebHostLib/templates/landing.html b/WebHostLib/templates/landing.html index fd45b78cfb74..b489ef18ac91 100644 --- a/WebHostLib/templates/landing.html +++ b/WebHostLib/templates/landing.html @@ -49,9 +49,9 @@

multiworld multi-game randomizer

our crazy idea into a reality.

- {{ seeds }} + {{ seeds }} games were generated and - {{ rooms }} + {{ rooms }} were hosted in the last 7 days.

diff --git a/WebHostLib/templates/multiFactorioTracker.html b/WebHostLib/templates/multiFactorioTracker.html index e8fa7b152cf2..389a79d411b5 100644 --- a/WebHostLib/templates/multiFactorioTracker.html +++ b/WebHostLib/templates/multiFactorioTracker.html @@ -1,46 +1,42 @@ {% extends "multiTracker.html" %} -{% block custom_table_headers %} +{# establish the to be tracked data. Display Name, factorio/AP internal name, display image #} +{%- set science_packs = [ + ("Logistic Science Pack", "logistic-science-pack", + "https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"), + ("Military Science Pack", "military-science-pack", + "https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"), + ("Chemical Science Pack", "chemical-science-pack", + "https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"), + ("Production Science Pack", "production-science-pack", + "https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"), + ("Utility Science Pack", "utility-science-pack", + "https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"), + ("Space Science Pack", "space-science-pack", + "https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"), +] -%} +{%- block custom_table_headers %} +{#- macro that creates a table header with display name and image -#} +{%- macro make_header(name, img_src) %} - Logistic Science Pack - - - Military Science Pack - - - Chemical Science Pack - - - Production Science Pack - - - Utility Science Pack - - - Space Science Pack + {{ name }} +{% endmacro -%} +{#- call the macro to build the table header -#} +{%- for name, internal_name, img_src in science_packs %} + {{ make_header(name, img_src) }} +{% endfor -%} {% endblock %} {% block custom_table_row scoped %} {% if games[player] == "Factorio" %} -{% set player_inventory = inventory[team][player] %} -{% set prog_science = player_inventory[custom_items["progressive-science-pack"]] %} -{% if player_inventory[custom_items["logistic-science-pack"]] or prog_science %}✔{% endif %} -{% if player_inventory[custom_items["military-science-pack"]] or prog_science > 1%}✔{% endif %} -{% if player_inventory[custom_items["chemical-science-pack"]] or prog_science > 2%}✔{% endif %} -{% if player_inventory[custom_items["production-science-pack"]] or prog_science > 3%}✔{% endif %} -{% if player_inventory[custom_items["utility-science-pack"]] or prog_science > 4%}✔{% endif %} -{% if player_inventory[custom_items["space-science-pack"]] or prog_science > 5%}✔{% endif %} + {%- set player_inventory = named_inventory[team][player] -%} + {%- set prog_science = player_inventory["progressive-science-pack"] -%} + {%- for name, internal_name, img_src in science_packs %} + {% if player_inventory[internal_name] or prog_science > loop.index0 %}✔{% endif %} + {% endfor -%} {% else %} -❌ -❌ -❌ -❌ -❌ -❌ + {%- for _ in science_packs %} + ❌ + {% endfor -%} {% endif %} {% endblock%} diff --git a/WebHostLib/templates/sc2wolTracker.html b/WebHostLib/templates/sc2wolTracker.html index af27e30b27f2..49c31a579544 100644 --- a/WebHostLib/templates/sc2wolTracker.html +++ b/WebHostLib/templates/sc2wolTracker.html @@ -11,7 +11,7 @@
- @@ -26,7 +26,7 @@ --> - @@ -37,120 +37,266 @@ + + + - - - - - - + + + + + - + + + + + + - - + + + + + + + + + + + + + + + + - + + - - - - - + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + - - - - - + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - + + + + + + + + - - - + + + + + + + + + + + + + + - - - - - - + + + + + + + + - @@ -165,36 +311,18 @@ - - - - - - - - - - + - - - - - - - - - - - + - diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html index 82f6348db2e9..f1514d83535d 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -4,16 +4,47 @@ Supported Games + + {% endblock %} {% block body %} {% include 'header/oceanHeader.html' %}

Currently Supported Games

+
+
+
+ + + +
+
{% for game_name in worlds | title_sorted %} {% set world = worlds[game_name] %} -

{{ game_name }}

-

+

+ {{ game_name }} +

+

Seed Info

{% endif %}
-
+ Starting Resources
+ Weapon & Armor Upgrades
+ Base
 
+ Infantry + Vehicles +
- Vehicles -
- Starships -
+ Starships +
- Dominion -
+ Mercenaries
- Lab Upgrades + + General Upgrades
+ Protoss Units
Rooms:  + {% call macros.list_rooms(seed.rooms | selectattr("owner", "eq", session["_id"])) %}
  • Create New Room diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 80ccc720a3d9..0d9ead795161 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -9,9 +9,9 @@ from werkzeug.exceptions import abort from MultiServer import Context, get_saving_second -from NetUtils import SlotType, NetworkSlot +from NetUtils import ClientStatus, SlotType, NetworkSlot from Utils import restricted_loads -from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package +from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package, games from worlds.alttp import Items from . import app, cache from .models import GameDataPackage, Room @@ -990,6 +990,7 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict SC2WOL_LOC_ID_OFFSET = 1000 SC2WOL_ITEM_ID_OFFSET = 1000 + icons = { "Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png", "Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png", @@ -1034,15 +1035,36 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Reaper": "https://static.wikia.nocookie.net/starcraft/images/7/7d/Reaper_SC2_Icon1.jpg", "Stimpack (Marine)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Marine)": "/static/static/icons/sc2/superstimpack.png", "Combat Shield (Marine)": "https://0rganics.org/archipelago/sc2wol/CombatShieldCampaign.png", + "Laser Targeting System (Marine)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Magrail Munitions (Marine)": "/static/static/icons/sc2/magrailmunitions.png", + "Optimized Logistics (Marine)": "/static/static/icons/sc2/optimizedlogistics.png", "Advanced Medic Facilities (Medic)": "https://0rganics.org/archipelago/sc2wol/AdvancedMedicFacilities.png", "Stabilizer Medpacks (Medic)": "https://0rganics.org/archipelago/sc2wol/StabilizerMedpacks.png", + "Restoration (Medic)": "/static/static/icons/sc2/restoration.png", + "Optical Flare (Medic)": "/static/static/icons/sc2/opticalflare.png", + "Optimized Logistics (Medic)": "/static/static/icons/sc2/optimizedlogistics.png", "Incinerator Gauntlets (Firebat)": "https://0rganics.org/archipelago/sc2wol/IncineratorGauntlets.png", "Juggernaut Plating (Firebat)": "https://0rganics.org/archipelago/sc2wol/JuggernautPlating.png", + "Stimpack (Firebat)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Firebat)": "/static/static/icons/sc2/superstimpack.png", + "Optimized Logistics (Firebat)": "/static/static/icons/sc2/optimizedlogistics.png", "Concussive Shells (Marauder)": "https://0rganics.org/archipelago/sc2wol/ConcussiveShellsCampaign.png", "Kinetic Foam (Marauder)": "https://0rganics.org/archipelago/sc2wol/KineticFoam.png", + "Stimpack (Marauder)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Marauder)": "/static/static/icons/sc2/superstimpack.png", + "Laser Targeting System (Marauder)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Magrail Munitions (Marauder)": "/static/static/icons/sc2/magrailmunitions.png", + "Internal Tech Module (Marauder)": "/static/static/icons/sc2/internalizedtechmodule.png", "U-238 Rounds (Reaper)": "https://0rganics.org/archipelago/sc2wol/U-238Rounds.png", "G-4 Clusterbomb (Reaper)": "https://0rganics.org/archipelago/sc2wol/G-4Clusterbomb.png", + "Stimpack (Reaper)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Reaper)": "/static/static/icons/sc2/superstimpack.png", + "Laser Targeting System (Reaper)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Advanced Cloaking Field (Reaper)": "/static/static/icons/sc2/terran-cloak-color.png", + "Spider Mines (Reaper)": "/static/static/icons/sc2/spidermine.png", + "Combat Drugs (Reaper)": "/static/static/icons/sc2/reapercombatdrugs.png", "Hellion": "https://static.wikia.nocookie.net/starcraft/images/5/56/Hellion_SC2_Icon1.jpg", "Vulture": "https://static.wikia.nocookie.net/starcraft/images/d/da/Vulture_WoL.jpg", @@ -1052,14 +1074,35 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png", "Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.png", - "Cerberus Mine (Vulture)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png", + "Hellbat Aspect (Hellion)": "/static/static/icons/sc2/hellionbattlemode.png", + "Smart Servos (Hellion)": "/static/static/icons/sc2/transformationservos.png", + "Optimized Logistics (Hellion)": "/static/static/icons/sc2/optimizedlogistics.png", + "Jump Jets (Hellion)": "/static/static/icons/sc2/jumpjets.png", + "Stimpack (Hellion)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Hellion)": "/static/static/icons/sc2/superstimpack.png", + "Cerberus Mine (Spider Mine)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png", + "High Explosive Munition (Spider Mine)": "/static/static/icons/sc2/high-explosive-spidermine.png", "Replenishable Magazine (Vulture)": "https://0rganics.org/archipelago/sc2wol/ReplenishableMagazine.png", + "Ion Thrusters (Vulture)": "/static/static/icons/sc2/emergencythrusters.png", + "Auto Launchers (Vulture)": "/static/static/icons/sc2/jotunboosters.png", "Multi-Lock Weapons System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Multi-LockWeaponsSystem.png", "Ares-Class Targeting System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Ares-ClassTargetingSystem.png", + "Jump Jets (Goliath)": "/static/static/icons/sc2/jumpjets.png", + "Optimized Logistics (Goliath)": "/static/static/icons/sc2/optimizedlogistics.png", "Tri-Lithium Power Cell (Diamondback)": "https://0rganics.org/archipelago/sc2wol/Tri-LithiumPowerCell.png", "Shaped Hull (Diamondback)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png", + "Hyperfluxor (Diamondback)": "/static/static/icons/sc2/hyperfluxor.png", + "Burst Capacitors (Diamondback)": "/static/static/icons/sc2/burstcapacitors.png", + "Optimized Logistics (Diamondback)": "/static/static/icons/sc2/optimizedlogistics.png", "Maelstrom Rounds (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/MaelstromRounds.png", "Shaped Blast (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/ShapedBlast.png", + "Jump Jets (Siege Tank)": "/static/static/icons/sc2/jumpjets.png", + "Spider Mines (Siege Tank)": "/static/static/icons/sc2/siegetank-spidermines.png", + "Smart Servos (Siege Tank)": "/static/static/icons/sc2/transformationservos.png", + "Graduating Range (Siege Tank)": "/static/static/icons/sc2/siegetankrange.png", + "Laser Targeting System (Siege Tank)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Advanced Siege Tech (Siege Tank)": "/static/static/icons/sc2/improvedsiegemode.png", + "Internal Tech Module (Siege Tank)": "/static/static/icons/sc2/internalizedtechmodule.png", "Medivac": "https://static.wikia.nocookie.net/starcraft/images/d/db/Medivac_SC2_Icon1.jpg", "Wraith": "https://static.wikia.nocookie.net/starcraft/images/7/75/Wraith_WoL.jpg", @@ -1069,25 +1112,77 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Rapid Deployment Tube (Medivac)": "https://0rganics.org/archipelago/sc2wol/RapidDeploymentTube.png", "Advanced Healing AI (Medivac)": "https://0rganics.org/archipelago/sc2wol/AdvancedHealingAI.png", + "Expanded Hull (Medivac)": "/static/static/icons/sc2/neosteelfortifiedarmor.png", + "Afterburners (Medivac)": "/static/static/icons/sc2/medivacemergencythrusters.png", "Tomahawk Power Cells (Wraith)": "https://0rganics.org/archipelago/sc2wol/TomahawkPowerCells.png", "Displacement Field (Wraith)": "https://0rganics.org/archipelago/sc2wol/DisplacementField.png", + "Advanced Laser Technology (Wraith)": "/static/static/icons/sc2/improvedburstlaser.png", "Ripwave Missiles (Viking)": "https://0rganics.org/archipelago/sc2wol/RipwaveMissiles.png", "Phobos-Class Weapons System (Viking)": "https://0rganics.org/archipelago/sc2wol/Phobos-ClassWeaponsSystem.png", - "Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png", + "Smart Servos (Viking)": "/static/static/icons/sc2/transformationservos.png", + "Magrail Munitions (Viking)": "/static/static/icons/sc2/magrailmunitions.png", + "Cross-Spectrum Dampeners (Banshee)": "/static/static/icons/sc2/crossspectrumdampeners.png", + "Advanced Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png", "Shockwave Missile Battery (Banshee)": "https://0rganics.org/archipelago/sc2wol/ShockwaveMissileBattery.png", + "Hyperflight Rotors (Banshee)": "/static/static/icons/sc2/hyperflightrotors.png", + "Laser Targeting System (Banshee)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Internal Tech Module (Banshee)": "/static/static/icons/sc2/internalizedtechmodule.png", "Missile Pods (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/MissilePods.png", "Defensive Matrix (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png", + "Tactical Jump (Battlecruiser)": "/static/static/icons/sc2/warpjump.png", + "Cloak (Battlecruiser)": "/static/static/icons/sc2/terran-cloak-color.png", + "ATX Laser Battery (Battlecruiser)": "/static/static/icons/sc2/specialordance.png", + "Optimized Logistics (Battlecruiser)": "/static/static/icons/sc2/optimizedlogistics.png", + "Internal Tech Module (Battlecruiser)": "/static/static/icons/sc2/internalizedtechmodule.png", "Ghost": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Ghost_SC2_Icon1.jpg", "Spectre": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Spectre_WoL.jpg", "Thor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/Thor_SC2_Icon1.jpg", + "Widow Mine": "/static/static/icons/sc2/widowmine.png", + "Cyclone": "/static/static/icons/sc2/cyclone.png", + "Liberator": "/static/static/icons/sc2/liberator.png", + "Valkyrie": "/static/static/icons/sc2/valkyrie.png", + "Ocular Implants (Ghost)": "https://0rganics.org/archipelago/sc2wol/OcularImplants.png", "Crius Suit (Ghost)": "https://0rganics.org/archipelago/sc2wol/CriusSuit.png", + "EMP Rounds (Ghost)": "/static/static/icons/sc2/terran-emp-color.png", + "Lockdown (Ghost)": "/static/static/icons/sc2/lockdown.png", "Psionic Lash (Spectre)": "https://0rganics.org/archipelago/sc2wol/PsionicLash.png", "Nyx-Class Cloaking Module (Spectre)": "https://0rganics.org/archipelago/sc2wol/Nyx-ClassCloakingModule.png", + "Impaler Rounds (Spectre)": "/static/static/icons/sc2/impalerrounds.png", "330mm Barrage Cannon (Thor)": "https://0rganics.org/archipelago/sc2wol/330mmBarrageCannon.png", "Immortality Protocol (Thor)": "https://0rganics.org/archipelago/sc2wol/ImmortalityProtocol.png", + "High Impact Payload (Thor)": "/static/static/icons/sc2/thorsiegemode.png", + "Smart Servos (Thor)": "/static/static/icons/sc2/transformationservos.png", + + "Optimized Logistics (Predator)": "/static/static/icons/sc2/optimizedlogistics.png", + "Drilling Claws (Widow Mine)": "/static/static/icons/sc2/drillingclaws.png", + "Concealment (Widow Mine)": "/static/static/icons/sc2/widowminehidden.png", + "Black Market Launchers (Widow Mine)": "/static/static/icons/sc2/widowmine-attackrange.png", + "Executioner Missiles (Widow Mine)": "/static/static/icons/sc2/widowmine-deathblossom.png", + "Mag-Field Accelerators (Cyclone)": "/static/static/icons/sc2/magfieldaccelerator.png", + "Mag-Field Launchers (Cyclone)": "/static/static/icons/sc2/cyclonerangeupgrade.png", + "Targeting Optics (Cyclone)": "/static/static/icons/sc2/targetingoptics.png", + "Rapid Fire Launchers (Cyclone)": "/static/static/icons/sc2/ripwavemissiles.png", + "Bio Mechanical Repair Drone (Raven)": "/static/static/icons/sc2/biomechanicaldrone.png", + "Spider Mines (Raven)": "/static/static/icons/sc2/siegetank-spidermines.png", + "Railgun Turret (Raven)": "/static/static/icons/sc2/autoturretblackops.png", + "Hunter-Seeker Weapon (Raven)": "/static/static/icons/sc2/specialordance.png", + "Interference Matrix (Raven)": "/static/static/icons/sc2/interferencematrix.png", + "Anti-Armor Missile (Raven)": "/static/static/icons/sc2/shreddermissile.png", + "Internal Tech Module (Raven)": "/static/static/icons/sc2/internalizedtechmodule.png", + "EMP Shockwave (Science Vessel)": "/static/static/icons/sc2/staticempblast.png", + "Defensive Matrix (Science Vessel)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png", + "Advanced Ballistics (Liberator)": "/static/static/icons/sc2/advanceballistics.png", + "Raid Artillery (Liberator)": "/static/static/icons/sc2/terrandefendermodestructureattack.png", + "Cloak (Liberator)": "/static/static/icons/sc2/terran-cloak-color.png", + "Laser Targeting System (Liberator)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Optimized Logistics (Liberator)": "/static/static/icons/sc2/optimizedlogistics.png", + "Enhanced Cluster Launchers (Valkyrie)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png", + "Shaped Hull (Valkyrie)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png", + "Burst Lasers (Valkyrie)": "/static/static/icons/sc2/improvedburstlaser.png", + "Afterburners (Valkyrie)": "/static/static/icons/sc2/medivacemergencythrusters.png", "War Pigs": "https://static.wikia.nocookie.net/starcraft/images/e/ed/WarPigs_SC2_Icon1.jpg", "Devil Dogs": "https://static.wikia.nocookie.net/starcraft/images/3/33/DevilDogs_SC2_Icon1.jpg", @@ -1109,14 +1204,15 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Tech Reactor": "https://static.wikia.nocookie.net/starcraft/images/c/c5/SC2_Lab_Tech_Reactor_Icon.png", "Orbital Strike": "https://static.wikia.nocookie.net/starcraft/images/d/df/SC2_Lab_Orb_Strike_Icon.png", - "Shrike Turret": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png", - "Fortified Bunker": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png", + "Shrike Turret (Bunker)": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png", + "Fortified Bunker (Bunker)": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png", "Planetary Fortress": "https://static.wikia.nocookie.net/starcraft/images/0/0b/SC2_Lab_PlanetFortress_Icon.png", "Perdition Turret": "https://static.wikia.nocookie.net/starcraft/images/a/af/SC2_Lab_PerdTurret_Icon.png", "Predator": "https://static.wikia.nocookie.net/starcraft/images/8/83/SC2_Lab_Predator_Icon.png", "Hercules": "https://static.wikia.nocookie.net/starcraft/images/4/40/SC2_Lab_Hercules_Icon.png", "Cellular Reactor": "https://static.wikia.nocookie.net/starcraft/images/d/d8/SC2_Lab_CellReactor_Icon.png", - "Regenerative Bio-Steel": "https://static.wikia.nocookie.net/starcraft/images/d/d3/SC2_Lab_BioSteel_Icon.png", + "Regenerative Bio-Steel Level 1": "/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png", + "Regenerative Bio-Steel Level 2": "/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png", "Hive Mind Emulator": "https://static.wikia.nocookie.net/starcraft/images/b/bc/SC2_Lab_Hive_Emulator_Icon.png", "Psi Disrupter": "https://static.wikia.nocookie.net/starcraft/images/c/cf/SC2_Lab_Psi_Disruptor_Icon.png", @@ -1132,40 +1228,71 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Nothing": "", } - sc2wol_location_ids = { - "Liberation Day": [SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 101, SC2WOL_LOC_ID_OFFSET + 102, SC2WOL_LOC_ID_OFFSET + 103, SC2WOL_LOC_ID_OFFSET + 104, SC2WOL_LOC_ID_OFFSET + 105, SC2WOL_LOC_ID_OFFSET + 106], - "The Outlaws": [SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 201], - "Zero Hour": [SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 301, SC2WOL_LOC_ID_OFFSET + 302, SC2WOL_LOC_ID_OFFSET + 303], - "Evacuation": [SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 401, SC2WOL_LOC_ID_OFFSET + 402, SC2WOL_LOC_ID_OFFSET + 403], - "Outbreak": [SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 501, SC2WOL_LOC_ID_OFFSET + 502], - "Safe Haven": [SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 601, SC2WOL_LOC_ID_OFFSET + 602, SC2WOL_LOC_ID_OFFSET + 603], - "Haven's Fall": [SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 701, SC2WOL_LOC_ID_OFFSET + 702, SC2WOL_LOC_ID_OFFSET + 703], - "Smash and Grab": [SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 801, SC2WOL_LOC_ID_OFFSET + 802, SC2WOL_LOC_ID_OFFSET + 803, SC2WOL_LOC_ID_OFFSET + 804], - "The Dig": [SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 901, SC2WOL_LOC_ID_OFFSET + 902, SC2WOL_LOC_ID_OFFSET + 903], - "The Moebius Factor": [SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1003, SC2WOL_LOC_ID_OFFSET + 1004, SC2WOL_LOC_ID_OFFSET + 1005, SC2WOL_LOC_ID_OFFSET + 1006, SC2WOL_LOC_ID_OFFSET + 1007, SC2WOL_LOC_ID_OFFSET + 1008], - "Supernova": [SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1101, SC2WOL_LOC_ID_OFFSET + 1102, SC2WOL_LOC_ID_OFFSET + 1103, SC2WOL_LOC_ID_OFFSET + 1104], - "Maw of the Void": [SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1201, SC2WOL_LOC_ID_OFFSET + 1202, SC2WOL_LOC_ID_OFFSET + 1203, SC2WOL_LOC_ID_OFFSET + 1204, SC2WOL_LOC_ID_OFFSET + 1205], - "Devil's Playground": [SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1301, SC2WOL_LOC_ID_OFFSET + 1302], - "Welcome to the Jungle": [SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1401, SC2WOL_LOC_ID_OFFSET + 1402, SC2WOL_LOC_ID_OFFSET + 1403], - "Breakout": [SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1501, SC2WOL_LOC_ID_OFFSET + 1502], - "Ghost of a Chance": [SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1601, SC2WOL_LOC_ID_OFFSET + 1602, SC2WOL_LOC_ID_OFFSET + 1603, SC2WOL_LOC_ID_OFFSET + 1604, SC2WOL_LOC_ID_OFFSET + 1605], - "The Great Train Robbery": [SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1701, SC2WOL_LOC_ID_OFFSET + 1702, SC2WOL_LOC_ID_OFFSET + 1703], - "Cutthroat": [SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1801, SC2WOL_LOC_ID_OFFSET + 1802, SC2WOL_LOC_ID_OFFSET + 1803, SC2WOL_LOC_ID_OFFSET + 1804], - "Engine of Destruction": [SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 1901, SC2WOL_LOC_ID_OFFSET + 1902, SC2WOL_LOC_ID_OFFSET + 1903, SC2WOL_LOC_ID_OFFSET + 1904, SC2WOL_LOC_ID_OFFSET + 1905], - "Media Blitz": [SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2001, SC2WOL_LOC_ID_OFFSET + 2002, SC2WOL_LOC_ID_OFFSET + 2003, SC2WOL_LOC_ID_OFFSET + 2004], - "Piercing the Shroud": [SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2101, SC2WOL_LOC_ID_OFFSET + 2102, SC2WOL_LOC_ID_OFFSET + 2103, SC2WOL_LOC_ID_OFFSET + 2104, SC2WOL_LOC_ID_OFFSET + 2105], - "Whispers of Doom": [SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2201, SC2WOL_LOC_ID_OFFSET + 2202, SC2WOL_LOC_ID_OFFSET + 2203], - "A Sinister Turn": [SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2301, SC2WOL_LOC_ID_OFFSET + 2302, SC2WOL_LOC_ID_OFFSET + 2303], - "Echoes of the Future": [SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2401, SC2WOL_LOC_ID_OFFSET + 2402], - "In Utter Darkness": [SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2501, SC2WOL_LOC_ID_OFFSET + 2502], - "Gates of Hell": [SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2601], - "Belly of the Beast": [SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2701, SC2WOL_LOC_ID_OFFSET + 2702, SC2WOL_LOC_ID_OFFSET + 2703], - "Shatter the Sky": [SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2801, SC2WOL_LOC_ID_OFFSET + 2802, SC2WOL_LOC_ID_OFFSET + 2803, SC2WOL_LOC_ID_OFFSET + 2804, SC2WOL_LOC_ID_OFFSET + 2805], + "Liberation Day": range(SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 200), + "The Outlaws": range(SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 300), + "Zero Hour": range(SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 400), + "Evacuation": range(SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 500), + "Outbreak": range(SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 600), + "Safe Haven": range(SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 700), + "Haven's Fall": range(SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 800), + "Smash and Grab": range(SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 900), + "The Dig": range(SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 1000), + "The Moebius Factor": range(SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1100), + "Supernova": range(SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1200), + "Maw of the Void": range(SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1300), + "Devil's Playground": range(SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1400), + "Welcome to the Jungle": range(SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1500), + "Breakout": range(SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1600), + "Ghost of a Chance": range(SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1700), + "The Great Train Robbery": range(SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1800), + "Cutthroat": range(SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1900), + "Engine of Destruction": range(SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 2000), + "Media Blitz": range(SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2100), + "Piercing the Shroud": range(SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2200), + "Whispers of Doom": range(SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2300), + "A Sinister Turn": range(SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2400), + "Echoes of the Future": range(SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2500), + "In Utter Darkness": range(SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2600), + "Gates of Hell": range(SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2700), + "Belly of the Beast": range(SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2800), + "Shatter the Sky": range(SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2900), } display_data = {} + # Grouped Items + grouped_item_ids = { + "Progressive Weapon Upgrade": 107 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Armor Upgrade": 108 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Infantry Upgrade": 109 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Vehicle Upgrade": 110 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Ship Upgrade": 111 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Weapon/Armor Upgrade": 112 + SC2WOL_ITEM_ID_OFFSET + } + grouped_item_replacements = { + "Progressive Weapon Upgrade": ["Progressive Infantry Weapon", "Progressive Vehicle Weapon", "Progressive Ship Weapon"], + "Progressive Armor Upgrade": ["Progressive Infantry Armor", "Progressive Vehicle Armor", "Progressive Ship Armor"], + "Progressive Infantry Upgrade": ["Progressive Infantry Weapon", "Progressive Infantry Armor"], + "Progressive Vehicle Upgrade": ["Progressive Vehicle Weapon", "Progressive Vehicle Armor"], + "Progressive Ship Upgrade": ["Progressive Ship Weapon", "Progressive Ship Armor"] + } + grouped_item_replacements["Progressive Weapon/Armor Upgrade"] = grouped_item_replacements["Progressive Weapon Upgrade"] + grouped_item_replacements["Progressive Armor Upgrade"] + replacement_item_ids = { + "Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Infantry Armor": 102 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET, + } + for grouped_item_name, grouped_item_id in grouped_item_ids.items(): + count: int = inventory[grouped_item_id] + if count > 0: + for replacement_item in grouped_item_replacements[grouped_item_name]: + replacement_id: int = replacement_item_ids[replacement_item] + inventory[replacement_id] = count + # Determine display for progressive items progressive_items = { "Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET, @@ -1173,7 +1300,15 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET, "Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET, "Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET, - "Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET + "Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Marine)": 208 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Firebat)": 226 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Marauder)": 228 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Reaper)": 250 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Hellion)": 259 + SC2WOL_ITEM_ID_OFFSET, + "Progressive High Impact Payload (Thor)": 361 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Cross-Spectrum Dampeners (Banshee)": 316 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Regenerative Bio-Steel": 617 + SC2WOL_ITEM_ID_OFFSET } progressive_names = { "Progressive Infantry Weapon": ["Infantry Weapons Level 1", "Infantry Weapons Level 1", "Infantry Weapons Level 2", "Infantry Weapons Level 3"], @@ -1181,14 +1316,27 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Progressive Vehicle Weapon": ["Vehicle Weapons Level 1", "Vehicle Weapons Level 1", "Vehicle Weapons Level 2", "Vehicle Weapons Level 3"], "Progressive Vehicle Armor": ["Vehicle Armor Level 1", "Vehicle Armor Level 1", "Vehicle Armor Level 2", "Vehicle Armor Level 3"], "Progressive Ship Weapon": ["Ship Weapons Level 1", "Ship Weapons Level 1", "Ship Weapons Level 2", "Ship Weapons Level 3"], - "Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"] + "Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"], + "Progressive Stimpack (Marine)": ["Stimpack (Marine)", "Stimpack (Marine)", "Super Stimpack (Marine)"], + "Progressive Stimpack (Firebat)": ["Stimpack (Firebat)", "Stimpack (Firebat)", "Super Stimpack (Firebat)"], + "Progressive Stimpack (Marauder)": ["Stimpack (Marauder)", "Stimpack (Marauder)", "Super Stimpack (Marauder)"], + "Progressive Stimpack (Reaper)": ["Stimpack (Reaper)", "Stimpack (Reaper)", "Super Stimpack (Reaper)"], + "Progressive Stimpack (Hellion)": ["Stimpack (Hellion)", "Stimpack (Hellion)", "Super Stimpack (Hellion)"], + "Progressive High Impact Payload (Thor)": ["High Impact Payload (Thor)", "High Impact Payload (Thor)", "Smart Servos (Thor)"], + "Progressive Cross-Spectrum Dampeners (Banshee)": ["Cross-Spectrum Dampeners (Banshee)", "Cross-Spectrum Dampeners (Banshee)", "Advanced Cross-Spectrum Dampeners (Banshee)"], + "Progressive Regenerative Bio-Steel": ["Regenerative Bio-Steel Level 1", "Regenerative Bio-Steel Level 1", "Regenerative Bio-Steel Level 2"] } for item_name, item_id in progressive_items.items(): level = min(inventory[item_id], len(progressive_names[item_name]) - 1) display_name = progressive_names[item_name][level] - base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_') + base_name = (item_name.split(maxsplit=1)[1].lower() + .replace(' ', '_') + .replace("-", "") + .replace("(", "") + .replace(")", "")) display_data[base_name + "_level"] = level display_data[base_name + "_url"] = icons[display_name] + display_data[base_name + "_name"] = display_name # Multi-items multi_items = { @@ -1220,12 +1368,12 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict checks_in_area['Total'] = sum(checks_in_area.values()) return render_template("sc2wolTracker.html", - inventory=inventory, icons=icons, - acquired_items={lookup_any_item_id_to_name[id] for id in inventory if - id in lookup_any_item_id_to_name}, - player=player, team=team, room=room, player_name=playerName, - checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, - **display_data) + inventory=inventory, icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id in inventory if + id in lookup_any_item_id_to_name}, + player=player, team=team, room=room, player_name=playerName, + checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, + **display_data) def __renderChecksfinder(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], inventory: Counter, team: int, player: int, playerName: str, @@ -1400,7 +1548,7 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s for player, name in enumerate(names, 1): player_names[team, player] = name states[team, player] = multisave.get("client_game_state", {}).get((team, player), 0) - if states[team, player] == 30: # Goal Completed + if states[team, player] == ClientStatus.CLIENT_GOAL and player not in groups: completed_worlds += 1 long_player_names = player_names.copy() for (team, player), alias in multisave.get("name_aliases", {}).items(): @@ -1423,9 +1571,12 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s ) -def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]: - inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data} - for teamnumber, team_data in data["checks_done"].items()} +def _get_inventory_data(data: typing.Dict[str, typing.Any]) \ + -> typing.Dict[int, typing.Dict[int, typing.Dict[int, int]]]: + inventory: typing.Dict[int, typing.Dict[int, typing.Dict[int, int]]] = { + teamnumber: {playernumber: collections.Counter() for playernumber in team_data} + for teamnumber, team_data in data["checks_done"].items() + } groups = data["groups"] @@ -1444,6 +1595,17 @@ def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, return inventory +def _get_named_inventory(inventory: typing.Dict[int, int], custom_items: typing.Dict[int, str] = None) \ + -> typing.Dict[str, int]: + """slow""" + if custom_items: + mapping = collections.ChainMap(custom_items, lookup_any_item_id_to_name) + else: + mapping = lookup_any_item_id_to_name + + return collections.Counter({mapping.get(item_id, None): count for item_id, count in inventory.items()}) + + @app.route('/tracker/') @cache.memoize(timeout=60) # multisave is currently created at most every minute def get_multiworld_tracker(tracker: UUID): @@ -1455,18 +1617,22 @@ def get_multiworld_tracker(tracker: UUID): return render_template("multiTracker.html", **data) +if "Factorio" in games: + @app.route('/tracker//Factorio') + @cache.memoize(timeout=60) # multisave is currently created at most every minute + def get_Factorio_multiworld_tracker(tracker: UUID): + data = _get_multiworld_tracker_data(tracker) + if not data: + abort(404) -@app.route('/tracker//Factorio') -@cache.memoize(timeout=60) # multisave is currently created at most every minute -def get_Factorio_multiworld_tracker(tracker: UUID): - data = _get_multiworld_tracker_data(tracker) - if not data: - abort(404) - - data["inventory"] = _get_inventory_data(data) - data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio") + data["inventory"] = _get_inventory_data(data) + data["named_inventory"] = {team_id : { + player_id: _get_named_inventory(inventory, data["custom_items"]) + for player_id, inventory in team_inventory.items() + } for team_id, team_inventory in data["inventory"].items()} + data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio") - return render_template("multiFactorioTracker.html", **data) + return render_template("multiFactorioTracker.html", **data) @app.route('/tracker//A Link to the Past') @@ -1517,7 +1683,7 @@ def attribute_item(team: int, recipient: int, item: int): for item_id in precollected: attribute_item(team, player, item_id) for location in locations_checked: - if location not in player_locations or location not in player_location_to_area[player]: + if location not in player_locations or location not in player_location_to_area.get(player, {}): continue item, recipient, flags = player_locations[location] recipients = groups.get(recipient, [recipient]) @@ -1596,5 +1762,7 @@ def attribute_item(team: int, recipient: int, item: int): multi_trackers: typing.Dict[str, typing.Callable] = { "A Link to the Past": get_LttP_multiworld_tracker, - "Factorio": get_Factorio_multiworld_tracker, } + +if "Factorio" in games: + multi_trackers["Factorio"] = get_Factorio_multiworld_tracker diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 89a839cfa364..e7ac033913e8 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -104,13 +104,21 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s # Factorio elif file.filename.endswith(".zip"): - _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3) + try: + _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3) + except ValueError: + flash("Error: Unexpected file found in .zip: " + file.filename) + return data = zfile.open(file, "r").read() files[int(slot_id[1:])] = data # All other files using the standard MultiWorld.get_out_file_name_base method else: - _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3) + try: + _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3) + except ValueError: + flash("Error: Unexpected file found in .zip: " + file.filename) + return data = zfile.open(file, "r").read() files[int(slot_id[1:])] = data diff --git a/data/lua/base64.lua b/data/lua/base64.lua new file mode 100644 index 000000000000..ebe80643531b --- /dev/null +++ b/data/lua/base64.lua @@ -0,0 +1,119 @@ +-- This file originates from this repository: https://github.com/iskolbin/lbase64 +-- It was modified to translate between base64 strings and lists of bytes instead of base64 strings and strings. + +local base64 = {} + +local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode +if not extract then + if _G._VERSION == "Lua 5.4" then + extract = load[[return function( v, from, width ) + return ( v >> from ) & ((1 << width) - 1) + end]]() + elseif _G.bit then -- LuaJIT + local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band + extract = function( v, from, width ) + return band( shr( v, from ), shl( 1, width ) - 1 ) + end + elseif _G._VERSION == "Lua 5.1" then + extract = function( v, from, width ) + local w = 0 + local flag = 2^from + for i = 0, width-1 do + local flag2 = flag + flag + if v % flag2 >= flag then + w = w + 2^i + end + flag = flag2 + end + return w + end + end +end + + +function base64.makeencoder( s62, s63, spad ) + local encoder = {} + for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J', + 'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y', + 'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n', + 'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2', + '3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do + encoder[b64code] = char:byte() + end + return encoder +end + +function base64.makedecoder( s62, s63, spad ) + local decoder = {} + for b64code, charcode in pairs( base64.makeencoder( s62, s63, spad )) do + decoder[charcode] = b64code + end + return decoder +end + +local DEFAULT_ENCODER = base64.makeencoder() +local DEFAULT_DECODER = base64.makedecoder() + +local char, concat = string.char, table.concat + +function base64.encode( arr, encoder ) + encoder = encoder or DEFAULT_ENCODER + local t, k, n = {}, 1, #arr + local lastn = n % 3 + for i = 1, n-lastn, 3 do + local a, b, c = arr[i], arr[i + 1], arr[i + 2] + local v = a*0x10000 + b*0x100 + c + local s + s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) + t[k] = s + k = k + 1 + end + if lastn == 2 then + local a, b = arr[n-1], arr[n] + local v = a*0x10000 + b*0x100 + t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64]) + elseif lastn == 1 then + local v = arr[n]*0x10000 + t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64]) + end + return concat( t ) +end + +function base64.decode( b64, decoder ) + decoder = decoder or DEFAULT_DECODER + local pattern = '[^%w%+%/%=]' + if decoder then + local s62, s63 + for charcode, b64code in pairs( decoder ) do + if b64code == 62 then s62 = charcode + elseif b64code == 63 then s63 = charcode + end + end + pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) ) + end + b64 = b64:gsub( pattern, '' ) + local t, k = {}, 1 + local n = #b64 + local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0 + for i = 1, padding > 0 and n-4 or n, 4 do + local a, b, c, d = b64:byte( i, i+3 ) + local s + local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d] + table.insert(t,extract(v,16,8)) + table.insert(t,extract(v,8,8)) + table.insert(t,extract(v,0,8)) + end + if padding == 1 then + local a, b, c = b64:byte( n-3, n-1 ) + local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + table.insert(t,extract(v,16,8)) + table.insert(t,extract(v,8,8)) + elseif padding == 2 then + local a, b = b64:byte( n-3, n-2 ) + local v = decoder[a]*0x40000 + decoder[b]*0x1000 + table.insert(t,extract(v,16,8)) + end + return t +end + +return base64 diff --git a/data/lua/connector_bizhawk_generic.lua b/data/lua/connector_bizhawk_generic.lua new file mode 100644 index 000000000000..b0b06de447bb --- /dev/null +++ b/data/lua/connector_bizhawk_generic.lua @@ -0,0 +1,564 @@ +--[[ +Copyright (c) 2023 Zunawe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local SCRIPT_VERSION = 1 + +--[[ +This script expects to receive JSON and will send JSON back. A message should +be a list of 1 or more requests which will be executed in order. Each request +will have a corresponding response in the same order. + +Every individual request and response is a JSON object with at minimum one +field `type`. The value of `type` determines what other fields may exist. + +To get the script version, instead of JSON, send "VERSION" to get the script +version directly (e.g. "2"). + +#### Ex. 1 + +Request: `[{"type": "PING"}]` + +Response: `[{"type": "PONG"}]` + +--- + +#### Ex. 2 + +Request: `[{"type": "LOCK"}, {"type": "HASH"}]` + +Response: `[{"type": "LOCKED"}, {"type": "HASH_RESPONSE", "value": "F7D18982"}]` + +--- + +#### Ex. 3 + +Request: + +```json +[ + {"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"}, + {"type": "READ", "address": 500, "size": 4, "domain": "ROM"} +] +``` + +Response: + +```json +[ + {"type": "GUARD_RESPONSE", "address": 100, "value": true}, + {"type": "READ_RESPONSE", "value": "dGVzdA=="} +] +``` + +--- + +#### Ex. 4 + +Request: + +```json +[ + {"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"}, + {"type": "READ", "address": 500, "size": 4, "domain": "ROM"} +] +``` + +Response: + +```json +[ + {"type": "GUARD_RESPONSE", "address": 100, "value": false}, + {"type": "GUARD_RESPONSE", "address": 100, "value": false} +] +``` + +--- + +### Supported Request Types + +- `PING` + Does nothing; resets timeout. + + Expected Response Type: `PONG` + +- `SYSTEM` + Returns the system of the currently loaded ROM (N64, GBA, etc...). + + Expected Response Type: `SYSTEM_RESPONSE` + +- `PREFERRED_CORES` + Returns the user's default cores for systems with multiple cores. If the + current ROM's system has multiple cores, the one that is currently + running is very probably the preferred core. + + Expected Response Type: `PREFERRED_CORES_RESPONSE` + +- `HASH` + Returns the hash of the currently loaded ROM calculated by BizHawk. + + Expected Response Type: `HASH_RESPONSE` + +- `GUARD` + Checks a section of memory against `expected_data`. If the bytes starting + at `address` do not match `expected_data`, the response will have `value` + set to `false`, and all subsequent requests will not be executed and + receive the same `GUARD_RESPONSE`. + + Expected Response Type: `GUARD_RESPONSE` + + Additional Fields: + - `address` (`int`): The address of the memory to check + - `expected_data` (string): A base64 string of contiguous data + - `domain` (`string`): The name of the memory domain the address + corresponds to + +- `LOCK` + Halts emulation and blocks on incoming requests until an `UNLOCK` request + is received or the client times out. All requests processed while locked + will happen on the same frame. + + Expected Response Type: `LOCKED` + +- `UNLOCK` + Resumes emulation after the current list of requests is done being + executed. + + Expected Response Type: `UNLOCKED` + +- `READ` + Reads an array of bytes at the provided address. + + Expected Response Type: `READ_RESPONSE` + + Additional Fields: + - `address` (`int`): The address of the memory to read + - `size` (`int`): The number of bytes to read + - `domain` (`string`): The name of the memory domain the address + corresponds to + +- `WRITE` + Writes an array of bytes to the provided address. + + Expected Response Type: `WRITE_RESPONSE` + + Additional Fields: + - `address` (`int`): The address of the memory to write to + - `value` (`string`): A base64 string representing the data to write + - `domain` (`string`): The name of the memory domain the address + corresponds to + +- `DISPLAY_MESSAGE` + Adds a message to the message queue which will be displayed using + `gui.addmessage` according to the message interval. + + Expected Response Type: `DISPLAY_MESSAGE_RESPONSE` + + Additional Fields: + - `message` (`string`): The string to display + +- `SET_MESSAGE_INTERVAL` + Sets the minimum amount of time to wait between displaying messages. + Potentially useful if you add many messages quickly but want players + to be able to read each of them. + + Expected Response Type: `SET_MESSAGE_INTERVAL_RESPONSE` + + Additional Fields: + - `value` (`number`): The number of seconds to set the interval to + + +### Response Types + +- `PONG` + Acknowledges `PING`. + +- `SYSTEM_RESPONSE` + Contains the name of the system for currently running ROM. + + Additional Fields: + - `value` (`string`): The returned system name + +- `PREFERRED_CORES_RESPONSE` + Contains the user's preferred cores for systems with multiple supported + cores. Currently includes NES, SNES, GB, GBC, DGB, SGB, PCE, PCECD, and + SGX. + + Additional Fields: + - `value` (`{[string]: [string]}`): A dictionary map from system name to + core name + +- `HASH_RESPONSE` + Contains the hash of the currently loaded ROM calculated by BizHawk. + + Additional Fields: + - `value` (`string`): The returned hash + +- `GUARD_RESPONSE` + The result of an attempted `GUARD` request. + + Additional Fields: + - `value` (`boolean`): true if the memory was validated, false if not + - `address` (`int`): The address of the memory that was invalid (the same + address provided by the `GUARD`, not the address of the individual invalid + byte) + +- `LOCKED` + Acknowledges `LOCK`. + +- `UNLOCKED` + Acknowledges `UNLOCK`. + +- `READ_RESPONSE` + Contains the result of a `READ` request. + + Additional Fields: + - `value` (`string`): A base64 string representing the read data + +- `WRITE_RESPONSE` + Acknowledges `WRITE`. + +- `DISPLAY_MESSAGE_RESPONSE` + Acknowledges `DISPLAY_MESSAGE`. + +- `SET_MESSAGE_INTERVAL_RESPONSE` + Acknowledges `SET_MESSAGE_INTERVAL`. + +- `ERROR` + Signifies that something has gone wrong while processing a request. + + Additional Fields: + - `err` (`string`): A description of the problem +]] + +local base64 = require("base64") +local socket = require("socket") +local json = require("json") + +-- Set to log incoming requests +-- Will cause lag due to large console output +local DEBUG = false + +local SOCKET_PORT = 43055 + +local STATE_NOT_CONNECTED = 0 +local STATE_CONNECTED = 1 + +local server = nil +local client_socket = nil + +local current_state = STATE_NOT_CONNECTED + +local timeout_timer = 0 +local message_timer = 0 +local message_interval = 0 +local prev_time = 0 +local current_time = 0 + +local locked = false + +local rom_hash = nil + +local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)") +lua_major = tonumber(lua_major) +lua_minor = tonumber(lua_minor) + +if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then + require("lua_5_3_compat") +end + +local bizhawk_version = client.getversion() +local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)") +bizhawk_major = tonumber(bizhawk_major) +bizhawk_minor = tonumber(bizhawk_minor) +if bizhawk_patch == "" then + bizhawk_patch = 0 +else + bizhawk_patch = tonumber(bizhawk_patch) +end + +function queue_push (self, value) + self[self.right] = value + self.right = self.right + 1 +end + +function queue_is_empty (self) + return self.right == self.left +end + +function queue_shift (self) + value = self[self.left] + self[self.left] = nil + self.left = self.left + 1 + return value +end + +function new_queue () + local queue = {left = 1, right = 1} + return setmetatable(queue, {__index = {is_empty = queue_is_empty, push = queue_push, shift = queue_shift}}) +end + +local message_queue = new_queue() + +function lock () + locked = true + client_socket:settimeout(2) +end + +function unlock () + locked = false + client_socket:settimeout(0) +end + +function process_request (req) + local res = {} + + if req["type"] == "PING" then + res["type"] = "PONG" + + elseif req["type"] == "SYSTEM" then + res["type"] = "SYSTEM_RESPONSE" + res["value"] = emu.getsystemid() + + elseif req["type"] == "PREFERRED_CORES" then + local preferred_cores = client.getconfig().PreferredCores + res["type"] = "PREFERRED_CORES_RESPONSE" + res["value"] = {} + res["value"]["NES"] = preferred_cores.NES + res["value"]["SNES"] = preferred_cores.SNES + res["value"]["GB"] = preferred_cores.GB + res["value"]["GBC"] = preferred_cores.GBC + res["value"]["DGB"] = preferred_cores.DGB + res["value"]["SGB"] = preferred_cores.SGB + res["value"]["PCE"] = preferred_cores.PCE + res["value"]["PCECD"] = preferred_cores.PCECD + res["value"]["SGX"] = preferred_cores.SGX + + elseif req["type"] == "HASH" then + res["type"] = "HASH_RESPONSE" + res["value"] = rom_hash + + elseif req["type"] == "GUARD" then + res["type"] = "GUARD_RESPONSE" + local expected_data = base64.decode(req["expected_data"]) + + local actual_data = memory.read_bytes_as_array(req["address"], #expected_data, req["domain"]) + + local data_is_validated = true + for i, byte in ipairs(actual_data) do + if byte ~= expected_data[i] then + data_is_validated = false + break + end + end + + res["value"] = data_is_validated + res["address"] = req["address"] + + elseif req["type"] == "LOCK" then + res["type"] = "LOCKED" + lock() + + elseif req["type"] == "UNLOCK" then + res["type"] = "UNLOCKED" + unlock() + + elseif req["type"] == "READ" then + res["type"] = "READ_RESPONSE" + res["value"] = base64.encode(memory.read_bytes_as_array(req["address"], req["size"], req["domain"])) + + elseif req["type"] == "WRITE" then + res["type"] = "WRITE_RESPONSE" + memory.write_bytes_as_array(req["address"], base64.decode(req["value"]), req["domain"]) + + elseif req["type"] == "DISPLAY_MESSAGE" then + res["type"] = "DISPLAY_MESSAGE_RESPONSE" + message_queue:push(req["message"]) + + elseif req["type"] == "SET_MESSAGE_INTERVAL" then + res["type"] = "SET_MESSAGE_INTERVAL_RESPONSE" + message_interval = req["value"] + + else + res["type"] = "ERROR" + res["err"] = "Unknown command: "..req["type"] + end + + return res +end + +-- Receive data from AP client and send message back +function send_receive () + local message, err = client_socket:receive() + + -- Handle errors + if err == "closed" then + if current_state == STATE_CONNECTED then + print("Connection to client closed") + end + current_state = STATE_NOT_CONNECTED + return + elseif err == "timeout" then + unlock() + return + elseif err ~= nil then + print(err) + current_state = STATE_NOT_CONNECTED + unlock() + return + end + + -- Reset timeout timer + timeout_timer = 5 + + -- Process received data + if DEBUG then + print("Received Message ["..emu.framecount().."]: "..'"'..message..'"') + end + + if message == "VERSION" then + local result, err client_socket:send(tostring(SCRIPT_VERSION).."\n") + else + local res = {} + local data = json.decode(message) + local failed_guard_response = nil + for i, req in ipairs(data) do + if failed_guard_response ~= nil then + res[i] = failed_guard_response + else + -- An error is more likely to cause an NLua exception than to return an error here + local status, response = pcall(process_request, req) + if status then + res[i] = response + + -- If the GUARD validation failed, skip the remaining commands + if response["type"] == "GUARD_RESPONSE" and not response["value"] then + failed_guard_response = response + end + else + res[i] = {type = "ERROR", err = response} + end + end + end + + client_socket:send(json.encode(res).."\n") + end +end + +function main () + server, err = socket.bind("localhost", SOCKET_PORT) + if err ~= nil then + print(err) + return + end + + while true do + current_time = socket.socket.gettime() + timeout_timer = timeout_timer - (current_time - prev_time) + message_timer = message_timer - (current_time - prev_time) + prev_time = current_time + + if message_timer <= 0 and not message_queue:is_empty() then + gui.addmessage(message_queue:shift()) + message_timer = message_interval + end + + if current_state == STATE_NOT_CONNECTED then + if emu.framecount() % 60 == 0 then + server:settimeout(2) + local client, timeout = server:accept() + if timeout == nil then + print("Client connected") + current_state = STATE_CONNECTED + client_socket = client + client_socket:settimeout(0) + else + print("No client found. Trying again...") + end + end + else + repeat + send_receive() + until not locked + + if timeout_timer <= 0 then + print("Client timed out") + current_state = STATE_NOT_CONNECTED + end + end + + coroutine.yield() + end +end + +event.onexit(function () + print("\n-- Restarting Script --\n") + if server ~= nil then + server:close() + end +end) + +if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then + print("Must use BizHawk 2.7.0 or newer") +elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then + print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.") +else + if emu.getsystemid() == "NULL" then + print("No ROM is loaded. Please load a ROM.") + while emu.getsystemid() == "NULL" do + emu.frameadvance() + end + end + + rom_hash = gameinfo.getromhash() + + print("Waiting for client to connect. Emulation will freeze intermittently until a client is found.\n") + + local co = coroutine.create(main) + function tick () + local status, err = coroutine.resume(co) + + if not status then + print("\nERROR: "..err) + print("Consider reporting this crash.\n") + + if server ~= nil then + server:close() + end + + co = coroutine.create(main) + end + end + + -- Gambatte has a setting which can cause script execution to become + -- misaligned, so for GB and GBC we explicitly set the callback on + -- vblank instead. + -- https://github.com/TASEmulators/BizHawk/issues/3711 + if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then + event.onmemoryexecute(tick, 0x40, "tick", "System Bus") + else + event.onframeend(tick) + end + + while true do + emu.frameadvance() + end +end diff --git a/data/lua/connector_tloz.lua b/data/lua/connector_tloz.lua index f48e4dfac1f2..4a2d2f25bf66 100644 --- a/data/lua/connector_tloz.lua +++ b/data/lua/connector_tloz.lua @@ -67,6 +67,7 @@ local itemsObtained = 0x0677 local takeAnyCavesChecked = 0x0678 local localTriforce = 0x0679 local bonusItemsObtained = 0x067A +local itemsObtainedHigh = 0x067B itemAPids = { ["Boomerang"] = 7100, @@ -173,11 +174,18 @@ for key, value in pairs(itemAPids) do itemIDNames[value] = key end +local function getItemsObtained() + return bit.bor(bit.lshift(u8(itemsObtainedHigh), 8), u8(itemsObtained)) +end +local function setItemsObtained(value) + wU8(itemsObtainedHigh, bit.rshift(value, 8)) + wU8(itemsObtained, bit.band(value, 0xFF)) +end local function determineItem(array) memdomain.ram() - currentItemsObtained = u8(itemsObtained) + currentItemsObtained = getItemsObtained() end @@ -364,8 +372,8 @@ local function gotItem(item) wU8(0x505, itemCode) wU8(0x506, 128) wU8(0x602, 4) - numberObtained = u8(itemsObtained) + 1 - wU8(itemsObtained, numberObtained) + numberObtained = getItemsObtained() + 1 + setItemsObtained(numberObtained) if itemName == "Boomerang" then gotBoomerang() end if itemName == "Bow" then gotBow() end if itemName == "Magical Boomerang" then gotMagicalBoomerang() end @@ -476,7 +484,7 @@ function processBlock(block) if i > u8(bonusItemsObtained) then if u8(0x505) == 0 then gotItem(item) - wU8(itemsObtained, u8(itemsObtained) - 1) + setItemsObtained(getItemsObtained() - 1) wU8(bonusItemsObtained, u8(bonusItemsObtained) + 1) end end @@ -494,7 +502,7 @@ function processBlock(block) for i, item in ipairs(itemsBlock) do memDomain.ram() if u8(0x505) == 0 then - if i > u8(itemsObtained) then + if i > getItemsObtained() then gotItem(item) end end @@ -546,7 +554,7 @@ function receive() retTable["gameMode"] = gameMode retTable["overworldHC"] = getHCLocation() retTable["overworldPB"] = getPBLocation() - retTable["itemsObtained"] = u8(itemsObtained) + retTable["itemsObtained"] = getItemsObtained() msg = json.encode(retTable).."\n" local ret, error = zeldaSocket:send(msg) if ret == nil then @@ -606,4 +614,4 @@ function main() end end -main() \ No newline at end of file +main() diff --git a/docs/contributing.md b/docs/contributing.md index 899c06b92279..4f7af029cce8 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,9 +1,11 @@ # Contributing Contributions are welcome. We have a few requests of any new contributors. +* Follow styling as designated in our [styling documentation](/docs/style.md). * Ensure that all changes which affect logic are covered by unit tests. * Do not introduce any unit test failures/regressions. -* Follow styling as designated in our [styling documentation](/docs/style.md). +* Turn on automated github actions in your fork to have github run all the unit tests after pushing. See example below: +![Github actions example](./img/github-actions-example.png) Otherwise, we tend to judge code on a case to case basis. diff --git a/docs/img/github-actions-example.png b/docs/img/github-actions-example.png new file mode 100644 index 000000000000..2363a3ed4c56 Binary files /dev/null and b/docs/img/github-actions-example.png differ diff --git a/docs/options api.md b/docs/options api.md index fdabd9facd8a..2c86833800c7 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -28,19 +28,23 @@ Choice, and defining `alias_true = option_full`. and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`. As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's -create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our -options: +create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass: ```python # Options.py +from dataclasses import dataclass + +from Options import Toggle, PerGameCommonOptions + + class StartingSword(Toggle): """Adds a sword to your starting inventory.""" display_name = "Start With Sword" -example_options = { - "starting_sword": StartingSword -} +@dataclass +class ExampleGameOptions(PerGameCommonOptions): + starting_sword: StartingSword ``` This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it @@ -48,27 +52,30 @@ to our world's `__init__.py`: ```python from worlds.AutoWorld import World -from .Options import options +from .Options import ExampleGameOptions class ExampleWorld(World): - option_definitions = options + # this gives the generator all the definitions for our options + options_dataclass = ExampleGameOptions + # this gives us typing hints for all the options we defined + options: ExampleGameOptions ``` ### Option Checking Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after world instantiation. These are created as attributes on the MultiWorld and can be accessed with -`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to +`self.options.my_option_name`. This is an instance of the option class, which supports direct comparison methods to relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is the option class's `value` attribute. For our example above we can do a simple check: ```python -if self.multiworld.starting_sword[self.player]: +if self.options.starting_sword: do_some_things() ``` or if I need a boolean object, such as in my slot_data I can access it as: ```python -start_with_sword = bool(self.multiworld.starting_sword[self.player].value) +start_with_sword = bool(self.options.starting_sword.value) ``` ## Generic Option Classes @@ -120,7 +127,7 @@ Like Toggle, but 1 (true) is the default value. A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with: ```python -if self.multiworld.sword_availability[self.player] == "early_sword": +if self.options.sword_availability == "early_sword": do_early_sword_things() ``` @@ -128,7 +135,7 @@ or: ```python from .Options import SwordAvailability -if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword: +if self.options.sword_availability == SwordAvailability.option_early_sword: do_early_sword_things() ``` @@ -160,7 +167,7 @@ within the world. Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any user defined string as a valid option, so will either need to be validated by adding a validation step to the option class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified -point, `self.multiworld.my_option[self.player].current_key` will always return a string. +point, `self.options.my_option.current_key` will always return a string. ### PlandoBosses An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports diff --git a/docs/running from source.md b/docs/running from source.md index c0f4bf580227..b7367308d8db 100644 --- a/docs/running from source.md +++ b/docs/running from source.md @@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r What you'll need: * [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version - * **Python 3.11 does not work currently** + * **Python 3.12 is currently unsupported** * pip: included in downloads from python.org, separate in many Linux distributions * Matching C compiler * possibly optional, read operating system specific sections @@ -30,7 +30,7 @@ After this, you should be able to run the programs. Recommended steps * Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads) - * **Python 3.11 does not work currently** + * **Python 3.12 is currently unsupported** * **Optional**: Download and install Visual Studio Build Tools from [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). diff --git a/docs/triage role expectations.md b/docs/triage role expectations.md new file mode 100644 index 000000000000..5b4cab227532 --- /dev/null +++ b/docs/triage role expectations.md @@ -0,0 +1,100 @@ +# Triage Role Expectations + +Users with Triage-level access are selected contributors who can and wish to proactively label/triage issues and pull +requests without being granted write access to the Archipelago repository. + +Triage users are not necessarily official members of the Archipelago organization, for the list of core maintainers, +please reference [ArchipelagoMW Members](https://github.com/orgs/ArchipelagoMW/people) page. + +## Access Permissions + +Triage users have the following permissions: + +* Apply/dismiss labels on all issues and pull requests. +* Close, reopen, and assign all issues and pull requests. +* Mark issues and pull requests as duplicate. +* Request pull request reviews from repository members. +* Hide comments in issues or pull requests from public view. + * Hidden comments are not deleted and can be reversed by another triage user or repository member with write access. +* And all other standard permissions granted to regular GitHub users. + +For more details on permissions granted by the Triage role, see +[GitHub's Role Documentation](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/repository-roles-for-an-organization). + +## Expectations + +Users with triage-level permissions have no expectation to review code, but, if desired, to review pull requests/issues +and apply the relevant labels and ping/request reviews from any relevant [code owners](./CODEOWNERS) for review. Triage +users are also expected not to close others' issues or pull requests without strong reason to do so (with exception of +`meta: invalid` or `meta: duplicate` scenarios, which are listed below). When in doubt, defer to a core maintainer. + +Triage users are not "moderators" for others' issues or pull requests. However, they may voice their opinions/feedback +on issues or pull requests, just the same as any other GitHub user contributing to Archipelago. + +## Labeling + +As of the time of writing this document, there are 15 distinct labels that can be applied to issues and pull requests. + +### Affects + +These labels notate if certain issues or pull requests affect critical aspects of Archipelago that may require specific +review. More than one of these labels can be used on a issue or pull request, if relevant. + +* `affects: core` is to be applied to issues/PRs that may affect core Archipelago functionality and should be reviewed +with additional scrutiny. + * Core is defined as any files not contained in the `WebHostLib` directory or individual world implementations + directories inside the `worlds` directory, not including `worlds/generic`. +* `affects: webhost` is to be applied to issues/PRs that may affect the core WebHost portion of Archipelago. In +general, this is anything being modified inside the `WebHostLib` directory or `WebHost.py` file. +* `affects: release/blocker` is to be applied for any issues/PRs that may either negatively impact (issues) or propose +to resolve critical issues (pull requests) that affect the current or next official release of Archipelago and should be +given top priority for review. + +### Is + +These labels notate what kinds of changes are being made or proposed in issues or pull requests. More than one of these +labels can be used on a issue or pull request, if relevant, but at least one of these labels should be applied to every +pull request and issue. + +* `is: bug/fix` is to be applied to issues/PRs that report or resolve an issue in core, web, or individual world +implementations. +* `is: documentation` is to be applied to issues/PRs that relate to adding, updating, or removing documentation in +core, web, or individual world implementations without modifying actual code. +* `is: enhancement` is to be applied to issues/PRs that relate to adding, modifying, or removing functionality in +core, web, or individual world implementations. +* `is: refactor/cleanup` is to be applied to issues/PRs that relate to reorganizing existing code to improve +readability or performance without adding, modifying, or removing functionality or fixing known regressions. +* `is: maintenance` is to be applied to issues/PRs that don't modify logic, refactor existing code, change features. +This is typically reserved for pull requests that need to update dependencies or increment version numbers without +resolving existing issues. +* `is: new game` is to be applied to any pull requests that introduce a new game for the first time to the `worlds` +directory. + * Issues should not be opened and classified with `is: new game`, and instead should be directed to the + #future-game-design channel in Archipelago for opening suggestions. If they are opened, they should be labeled + with `meta: invalid` and closed. + * Pull requests for new games should only have this label, as enhancement, documentation, bug/fix, refactor, and + possibly maintenance is implied. + +### Meta + +These labels allow additional quick meta information for contributors or reviewers for issues and pull requests. They +have specific situations where they should be applied. + +* `meta: duplicate` is to be applied to any issues/PRs that are duplicate of another issue/PR that was already opened. + * These should be immediately closed after leaving a comment, directing to the original issue or pull request. +* `meta: invalid` is to be applied to any issues/PRs that do not relate to Archipelago or are inappropriate for +discussion on GitHub. + * These should be immediately closed afterwards. +* `meta: help wanted` is to be applied to any issues/PRs that require additional attention for whatever reason. + * These should include a comment describing what kind of help is requested when the label is added. + * Some common reasons include, but are not limited to: Breaking API changes that require developer input/testing or + pull requests with large line changes that need additional reviewers to be reviewed effectively. + * This label may require some programming experience and familiarity with Archipelago source to determine if + requesting additional attention for help is warranted. +* `meta: good first issue` is to be applied to any issues that may be a good starting ground for new contributors to try +and tackle. + * This label may require some programming experience and familiarity with Archipelago source to determine if an + issue is a "good first issue". +* `meta: wontfix` is to be applied for any issues/PRs that are opened that will not be actioned because it's out of +scope or determined to not be an issue. + * This should be reserved for use by a world's code owner(s) on their relevant world or by core maintainers. diff --git a/docs/world api.md b/docs/world api.md index 7a7f37b17ce4..b128e2b146b4 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -86,9 +86,11 @@ inside a `World` object. ### Player Options Players provide customized settings for their World in the form of yamls. -Those are accessible through `self.multiworld.[self.player]`. A dict -of valid options has to be provided in `self.option_definitions`. Options are automatically -added to the `World` object for easy access. +A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`. +(It must be a subclass of `PerGameCommonOptions`.) +Option results are automatically added to the `World` object for easy access. +Those are accessible through `self.options.`, and you can get a dictionary of the option values via +`self.options.as_dict()`, passing the desired options as strings. ### World Settings @@ -221,11 +223,11 @@ See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requireme AP will only import the `__init__.py`. Depending on code size it makes sense to use multiple files and use relative imports to access them. -e.g. `from .Options import mygame_options` from your `__init__.py` will load -`worlds//Options.py` and make its `mygame_options` accessible. +e.g. `from .Options import MyGameOptions` from your `__init__.py` will load +`world/[world_name]/Options.py` and make its `MyGameOptions` accessible. When imported names pile up it may be easier to use `from . import Options` -and access the variable as `Options.mygame_options`. +and access the variable as `Options.MyGameOptions`. Imports from directories outside your world should use absolute imports. Correct use of relative / absolute imports is required for zipped worlds to @@ -273,8 +275,9 @@ Each option has its own class, inherits from a base option type, has a docstring to describe it and a `display_name` property for display on the website and in spoiler logs. -The actual name as used in the yaml is defined in a `Dict[str, AssembleOptions]`, that is -assigned to the world under `self.option_definitions`. +The actual name as used in the yaml is defined via the field names of a `dataclass` that is +assigned to the world under `self.options_dataclass`. By convention, the strings +that define your option names should be in `snake_case`. Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. For more see `Options.py` in AP's base directory. @@ -309,8 +312,8 @@ default = 0 ```python # Options.py -from Options import Toggle, Range, Choice, Option -import typing +from dataclasses import dataclass +from Options import Toggle, Range, Choice, PerGameCommonOptions class Difficulty(Choice): """Sets overall game difficulty.""" @@ -333,23 +336,27 @@ class FixXYZGlitch(Toggle): """Fixes ABC when you do XYZ""" display_name = "Fix XYZ Glitch" -# By convention we call the options dict variable `_options`. -mygame_options: typing.Dict[str, AssembleOptions] = { - "difficulty": Difficulty, - "final_boss_hp": FinalBossHP, - "fix_xyz_glitch": FixXYZGlitch, -} +# By convention, we call the options dataclass `Options`. +# It has to be derived from 'PerGameCommonOptions'. +@dataclass +class MyGameOptions(PerGameCommonOptions): + difficulty: Difficulty + final_boss_hp: FinalBossHP + fix_xyz_glitch: FixXYZGlitch ``` + ```python # __init__.py from worlds.AutoWorld import World -from .Options import mygame_options # import the options dict +from .Options import MyGameOptions # import the options dataclass + class MyGameWorld(World): - #... - option_definitions = mygame_options # assign the options dict to the world - #... + # ... + options_dataclass = MyGameOptions # assign the options dataclass to the world + options: MyGameOptions # typing for option results + # ... ``` ### A World Class Skeleton @@ -359,13 +366,14 @@ class MyGameWorld(World): import settings import typing -from .Options import mygame_options # the options we defined earlier +from .Options import MyGameOptions # the options we defined earlier from .Items import mygame_items # data used below to add items to the World from .Locations import mygame_locations # same as above from worlds.AutoWorld import World from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification + class MyGameItem(Item): # or from Items import MyGameItem game = "My Game" # name of the game/world this item is from @@ -374,6 +382,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation game = "My Game" # name of the game/world this location is in + class MyGameSettings(settings.Group): class RomFile(settings.SNESRomPath): """Insert help text for host.yaml here.""" @@ -384,7 +393,8 @@ class MyGameSettings(settings.Group): class MyGameWorld(World): """Insert description of the world/game here.""" game = "My Game" # name of the game/world - option_definitions = mygame_options # options the player can set + options_dataclass = MyGameOptions # options the player can set + options: MyGameOptions # typing hints for option results settings: typing.ClassVar[MyGameSettings] # will be automatically assigned from type hint topology_present = True # show path to required location checks in spoiler @@ -460,7 +470,7 @@ In addition, the following methods can be implemented and are called in this ord ```python def generate_early(self) -> None: # read player settings to world instance - self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value + self.final_boss_hp = self.options.final_boss_hp.value ``` #### create_item @@ -559,6 +569,12 @@ def generate_basic(self) -> None: # in most cases it's better to do this at the same time the itempool is # filled to avoid accidental duplicates: # manually placed and still in the itempool + + # for debugging purposes, you may want to visualize the layout of your world. Uncomment the following code to + # write a PlantUML diagram to the file "my_world.puml" that can help you see whether your regions and locations + # are connected and placed as desired + # from Utils import visualize_regions + # visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") ``` ### Setting Rules @@ -681,9 +697,9 @@ def generate_output(self, output_directory: str): in self.multiworld.precollected_items[self.player]], "final_boss_hp": self.final_boss_hp, # store option name "easy", "normal" or "hard" for difficuly - "difficulty": self.multiworld.difficulty[self.player].current_key, + "difficulty": self.options.difficulty.current_key, # store option value True or False for fixing a glitch - "fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value, + "fix_xyz_glitch": self.options.fix_xyz_glitch.value, } # point to a ROM specified by the installation src = self.settings.rom_file @@ -696,6 +712,26 @@ def generate_output(self, output_directory: str): generate_mod(src, out_file, data) ``` +### Slot Data + +If the game client needs to know information about the generated seed, a preferred method of transferring the data +is through the slot data. This can be filled from the `fill_slot_data` method of your world by returning a `Dict[str, Any]`, +but should be limited to data that is absolutely necessary to not waste resources. Slot data is sent to your client once +it has successfully [connected](network%20protocol.md#connected). +If you need to know information about locations in your world, instead +of propagating the slot data, it is preferable to use [LocationScouts](network%20protocol.md#locationscouts) since that +data already exists on the server. The most common usage of slot data is to send option results that the client needs +to be aware of. + +```python +def fill_slot_data(self): + # in order for our game client to handle the generated seed correctly we need to know what the user selected + # for their difficulty and final boss HP + # a dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting + # the options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the option's value + return self.options.as_dict("difficulty", "final_boss_hp") +``` + ### Documentation Each world implementation should have a tutorial and a game info page. These are both rendered on the website by reading @@ -723,8 +759,9 @@ multiworld for each test written using it. Within subsequent modules, classes sh TestBase, and can then define options to test in the class body, and run tests in each test method. Example `__init__.py` + ```python -from test.TestBase import WorldTestBase +from test.test_base import WorldTestBase class MyGameTestBase(WorldTestBase): diff --git a/inno_setup.iss b/inno_setup.iss index 147cd74dca07..3c1bdc4571e0 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -74,6 +74,7 @@ Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning +Name: "client/bizhawk"; Description: "BizHawk Client"; Types: full playing Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 @@ -122,6 +123,7 @@ Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignorev Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni +Source: "{#source_path}\ArchipelagoBizHawkClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/bizhawk Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft @@ -146,6 +148,7 @@ Name: "{group}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe" Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni +Name: "{group}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Components: client/bizhawk Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot @@ -166,6 +169,7 @@ Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopic Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"; Tasks: desktopicon Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Tasks: desktopicon; Components: server Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni +Name: "{commondesktop}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Tasks: desktopicon; Components: client/bizhawk Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot diff --git a/kvui.py b/kvui.py index 77b96b896fd0..71bf80c86d9b 100644 --- a/kvui.py +++ b/kvui.py @@ -1,7 +1,17 @@ import os import logging +import sys import typing +if sys.platform == "win32": + import ctypes + # kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout + # by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's + try: + ctypes.windll.shcore.SetProcessDpiAwareness(0) + except FileNotFoundError: # shcore may not be found on <= Windows 7 + pass # TODO: remove silent except when Python 3.8 is phased out. + os.environ["KIVY_NO_CONSOLELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1" os.environ["KIVY_NO_ARGS"] = "1" diff --git a/playerSettings.yaml b/playerSettings.yaml index e28963ddb340..f9585da246b8 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -26,7 +26,7 @@ name: YourName{number} # Your name in-game. Spaces will be replaced with undersc game: # Pick a game to play A Link to the Past: 1 requires: - version: 0.3.3 # Version of Archipelago required for this yaml to work as expected. + version: 0.4.3 # Version of Archipelago required for this yaml to work as expected. A Link to the Past: progression_balancing: # A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. @@ -114,6 +114,9 @@ A Link to the Past: different_world: 0 universal: 0 start_with: 0 + key_drop_shuffle: # Shuffle keys found in pots or dropped from killed enemies + off: 50 + on: 0 compass_shuffle: # Compass Placement original_dungeon: 50 own_dungeons: 0 diff --git a/requirements.txt b/requirements.txt index 610892848d15..bfc637a80a2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ colorama>=0.4.5 websockets>=11.0.3 PyYAML>=6.0.1 -jellyfish>=1.0.0 +jellyfish>=1.0.1 jinja2>=3.1.2 schema>=0.7.5 kivy>=2.2.0 @@ -9,4 +9,4 @@ bsdiff4>=1.2.3 platformdirs>=3.9.1 certifi>=2023.7.22 cython>=0.29.35 -cymem>=2.0.7 +cymem>=2.0.8 diff --git a/settings.py b/settings.py index 4c9c1d1c3042..acae86095cda 100644 --- a/settings.py +++ b/settings.py @@ -118,7 +118,7 @@ def get_type_hints(cls) -> Dict[str, Any]: cls._type_cache = typing.get_type_hints(cls, globalns=mod_dict, localns=cls.__dict__) return cls._type_cache - def get(self, key: str, default: Any) -> Any: + def get(self, key: str, default: Any = None) -> Any: if key in self: return self[key] return default @@ -694,6 +694,25 @@ class SnesRomStart(str): snes_rom_start: Union[SnesRomStart, bool] = True +class BizHawkClientOptions(Group): + class EmuHawkPath(UserFilePath): + """ + The location of the EmuHawk you want to auto launch patched ROMs with + """ + is_exe = True + description = "EmuHawk Executable" + + class RomStart(str): + """ + Set this to true to autostart a patched ROM in BizHawk with the connector script, + to false to never open the patched rom automatically, + or to a path to an external program to open the ROM file with that instead. + """ + + emuhawk_path: EmuHawkPath = EmuHawkPath(None) + rom_start: Union[RomStart, bool] = True + + # Top-level group with lazy loading of worlds class Settings(Group): @@ -701,6 +720,7 @@ class Settings(Group): server_options: ServerOptions = ServerOptions() generator: GeneratorOptions = GeneratorOptions() sni_options: SNIOptions = SNIOptions() + bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions() _filename: Optional[str] = None diff --git a/setup.py b/setup.py index 212bcc5d0954..cea60dab8320 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,6 @@ "Clique", "DLCQuest", "Final Fantasy", - "Hylics 2", "Kingdom Hearts 2", "Lufia II Ancient Cave", "Meritous", @@ -80,7 +79,6 @@ "Raft", "Secret of Evermore", "Slay the Spire", - "Starcraft 2 Wings of Liberty", "Sudoku", "Super Mario 64", "VVVVVV", @@ -91,6 +89,7 @@ # LogicMixin is broken before 3.10 import revamp if sys.version_info < (3,10): non_apworlds.add("Hollow Knight") + non_apworlds.add("Starcraft 2 Wings of Liberty") def download_SNI(): print("Updating SNI") @@ -370,6 +369,10 @@ def run(self): assert not non_apworlds - set(AutoWorldRegister.world_types), \ f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld" folders_to_remove: typing.List[str] = [] + disabled_worlds_folder = "worlds_disabled" + for entry in os.listdir(disabled_worlds_folder): + if os.path.isdir(os.path.join(disabled_worlds_folder, entry)): + folders_to_remove.append(entry) generate_yaml_templates(self.buildfolder / "Players" / "Templates", False) for worldname, worldtype in AutoWorldRegister.world_types.items(): if worldname not in non_apworlds: diff --git a/test/TestBase.py b/test/TestBase.py index dc79ad2855ed..bfd92346d301 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -1,254 +1,3 @@ -import pathlib -import typing -import unittest -from argparse import Namespace - -import Utils -from test.general import gen_steps -from worlds import AutoWorld -from worlds.AutoWorld import call_all - -file_path = pathlib.Path(__file__).parent.parent -Utils.local_path.cached_path = file_path - -from BaseClasses import MultiWorld, CollectionState, ItemClassification, Item -from worlds.alttp.Items import ItemFactory - - -class TestBase(unittest.TestCase): - multiworld: MultiWorld - _state_cache = {} - - def get_state(self, items): - if (self.multiworld, tuple(items)) in self._state_cache: - return self._state_cache[self.multiworld, tuple(items)] - state = CollectionState(self.multiworld) - for item in items: - item.classification = ItemClassification.progression - state.collect(item) - state.sweep_for_events() - self._state_cache[self.multiworld, tuple(items)] = state - return state - - def get_path(self, state, region): - def flist_to_iter(node): - while node: - value, node = node - yield value - - from itertools import zip_longest - reversed_path_as_flist = state.path.get(region, (region, None)) - string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) - # Now we combine the flat string list into (region, exit) pairs - pathsiter = iter(string_path_flat) - pathpairs = zip_longest(pathsiter, pathsiter) - return list(pathpairs) - - def run_location_tests(self, access_pool): - for i, (location, access, *item_pool) in enumerate(access_pool): - items = item_pool[0] - all_except = item_pool[1] if len(item_pool) > 1 else None - state = self._get_items(item_pool, all_except) - path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region) - with self.subTest(msg="Reach Location", location=location, access=access, items=items, - all_except=all_except, path=path, entry=i): - - self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access) - - # check for partial solution - if not all_except and access: # we are not supposed to be able to reach location with partial inventory - for missing_item in item_pool[0]: - with self.subTest(msg="Location reachable without required item", location=location, - items=item_pool[0], missing_item=missing_item, entry=i): - state = self._get_items_partial(item_pool, missing_item) - self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False) - - def run_entrance_tests(self, access_pool): - for i, (entrance, access, *item_pool) in enumerate(access_pool): - items = item_pool[0] - all_except = item_pool[1] if len(item_pool) > 1 else None - state = self._get_items(item_pool, all_except) - path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region) - with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items, - all_except=all_except, path=path, entry=i): - - self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access) - - # check for partial solution - if not all_except and access: # we are not supposed to be able to reach location with partial inventory - for missing_item in item_pool[0]: - with self.subTest(msg="Entrance reachable without required item", entrance=entrance, - items=item_pool[0], missing_item=missing_item, entry=i): - state = self._get_items_partial(item_pool, missing_item) - self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False) - - def _get_items(self, item_pool, all_except): - if all_except and len(all_except) > 0: - items = self.multiworld.itempool[:] - items = [item for item in items if - item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)] - items.extend(ItemFactory(item_pool[0], 1)) - else: - items = ItemFactory(item_pool[0], 1) - return self.get_state(items) - - def _get_items_partial(self, item_pool, missing_item): - new_items = item_pool[0].copy() - new_items.remove(missing_item) - items = ItemFactory(new_items, 1) - return self.get_state(items) - - -class WorldTestBase(unittest.TestCase): - options: typing.Dict[str, typing.Any] = {} - multiworld: MultiWorld - - game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore" - auto_construct: typing.ClassVar[bool] = True - """ automatically set up a world for each test in this class """ - - def setUp(self) -> None: - if self.auto_construct: - self.world_setup() - - def world_setup(self, seed: typing.Optional[int] = None) -> None: - if type(self) is WorldTestBase or \ - (hasattr(WorldTestBase, self._testMethodName) - and not self.run_default_tests and - getattr(self, self._testMethodName).__code__ is - getattr(WorldTestBase, self._testMethodName, None).__code__): - return # setUp gets called for tests defined in the base class. We skip world_setup here. - if not hasattr(self, "game"): - raise NotImplementedError("didn't define game name") - self.multiworld = MultiWorld(1) - self.multiworld.game[1] = self.game - self.multiworld.player_name = {1: "Tester"} - self.multiworld.set_seed(seed) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items(): - setattr(args, name, { - 1: option.from_any(self.options.get(name, getattr(option, "default"))) - }) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() - for step in gen_steps: - call_all(self.multiworld, step) - - # methods that can be called within tests - def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]]) -> None: - """Collects all pre-placed items and items in the multiworld itempool except those provided""" - if isinstance(item_names, str): - item_names = (item_names,) - for item in self.multiworld.get_items(): - if item.name not in item_names: - self.multiworld.state.collect(item) - - def get_item_by_name(self, item_name: str) -> Item: - """Returns the first item found in placed items, or in the itempool with the matching name""" - for item in self.multiworld.get_items(): - if item.name == item_name: - return item - raise ValueError("No such item") - - def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: - """Returns actual items from the itempool that match the provided name(s)""" - if isinstance(item_names, str): - item_names = (item_names,) - return [item for item in self.multiworld.itempool if item.name in item_names] - - def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: - """ collect all of the items in the item pool that have the given names """ - items = self.get_items_by_name(item_names) - self.collect(items) - return items - - def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: - """Collects the provided item(s) into state""" - if isinstance(items, Item): - items = (items,) - for item in items: - self.multiworld.state.collect(item) - - def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: - """Removes the provided item(s) from state""" - if isinstance(items, Item): - items = (items,) - for item in items: - if item.location and item.location.event and item.location in self.multiworld.state.events: - self.multiworld.state.events.remove(item.location) - self.multiworld.state.remove(item) - - def can_reach_location(self, location: str) -> bool: - """Determines if the current state can reach the provide location name""" - return self.multiworld.state.can_reach(location, "Location", 1) - - def can_reach_entrance(self, entrance: str) -> bool: - """Determines if the current state can reach the provided entrance name""" - return self.multiworld.state.can_reach(entrance, "Entrance", 1) - - def count(self, item_name: str) -> int: - """Returns the amount of an item currently in state""" - return self.multiworld.state.count(item_name, 1) - - def assertAccessDependency(self, - locations: typing.List[str], - possible_items: typing.Iterable[typing.Iterable[str]]) -> None: - """Asserts that the provided locations can't be reached without the listed items but can be reached with any - one of the provided combinations""" - all_items = [item_name for item_names in possible_items for item_name in item_names] - - self.collect_all_but(all_items) - for location in self.multiworld.get_locations(): - loc_reachable = self.multiworld.state.can_reach(location) - self.assertEqual(loc_reachable, location.name not in locations, - f"{location.name} is reachable without {all_items}" if loc_reachable - else f"{location.name} is not reachable without {all_items}") - for item_names in possible_items: - items = self.collect_by_name(item_names) - for location in locations: - self.assertTrue(self.can_reach_location(location), - f"{location} not reachable with {item_names}") - self.remove(items) - - def assertBeatable(self, beatable: bool): - """Asserts that the game can be beaten with the current state""" - self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable) - - # following tests are automatically run - @property - def run_default_tests(self) -> bool: - """Not possible or identical to the base test that's always being run already""" - return (self.options - or self.setUp.__code__ is not WorldTestBase.setUp.__code__ - or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__) - - @property - def constructed(self) -> bool: - """A multiworld has been constructed by this point""" - return hasattr(self, "game") and hasattr(self, "multiworld") - - def testAllStateCanReachEverything(self): - """Ensure all state can reach everything and complete the game with the defined options""" - if not (self.run_default_tests and self.constructed): - return - with self.subTest("Game", game=self.game): - excluded = self.multiworld.exclude_locations[1].value - state = self.multiworld.get_all_state(False) - for location in self.multiworld.get_locations(): - if location.name not in excluded: - with self.subTest("Location should be reached", location=location): - reachable = location.can_reach(state) - self.assertTrue(reachable, f"{location.name} unreachable") - with self.subTest("Beatable"): - self.multiworld.state = state - self.assertBeatable(True) - - def testEmptyStateCanReachSomething(self): - """Ensure empty state can reach at least one location with the defined options""" - if not (self.run_default_tests and self.constructed): - return - with self.subTest("Game", game=self.game): - state = CollectionState(self.multiworld) - locations = self.multiworld.get_reachable_locations(state, 1) - self.assertGreater(len(locations), 0, - "Need to be able to reach at least one location to get started.") +from .bases import TestBase, WorldTestBase +from warnings import warn +warn("TestBase was renamed to bases", DeprecationWarning) diff --git a/test/__init__.py b/test/__init__.py index 32622f65a927..37ebe3f62743 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,3 +1,4 @@ +import pathlib import warnings import settings @@ -5,3 +6,13 @@ warnings.simplefilter("always") settings.no_gui = True settings.skip_autosave = True + +import ModuleUpdate + +ModuleUpdate.update_ran = True # don't upgrade + +import Utils + +file_path = pathlib.Path(__file__).parent.parent +Utils.local_path.cached_path = file_path +Utils.user_path() # initialize cached_path diff --git a/test/bases.py b/test/bases.py new file mode 100644 index 000000000000..5fe4df2014c1 --- /dev/null +++ b/test/bases.py @@ -0,0 +1,309 @@ +import typing +import unittest +from argparse import Namespace + +from test.general import gen_steps +from worlds import AutoWorld +from worlds.AutoWorld import call_all + +from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item +from worlds.alttp.Items import ItemFactory + + +class TestBase(unittest.TestCase): + multiworld: MultiWorld + _state_cache = {} + + def get_state(self, items): + if (self.multiworld, tuple(items)) in self._state_cache: + return self._state_cache[self.multiworld, tuple(items)] + state = CollectionState(self.multiworld) + for item in items: + item.classification = ItemClassification.progression + state.collect(item, event=True) + state.sweep_for_events() + state.update_reachable_regions(1) + self._state_cache[self.multiworld, tuple(items)] = state + return state + + def get_path(self, state, region): + def flist_to_iter(node): + while node: + value, node = node + yield value + + from itertools import zip_longest + reversed_path_as_flist = state.path.get(region, (region, None)) + string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) + # Now we combine the flat string list into (region, exit) pairs + pathsiter = iter(string_path_flat) + pathpairs = zip_longest(pathsiter, pathsiter) + return list(pathpairs) + + def run_location_tests(self, access_pool): + for i, (location, access, *item_pool) in enumerate(access_pool): + items = item_pool[0] + all_except = item_pool[1] if len(item_pool) > 1 else None + state = self._get_items(item_pool, all_except) + path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region) + with self.subTest(msg="Reach Location", location=location, access=access, items=items, + all_except=all_except, path=path, entry=i): + + self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, + f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}") + + # check for partial solution + if not all_except and access: # we are not supposed to be able to reach location with partial inventory + for missing_item in item_pool[0]: + with self.subTest(msg="Location reachable without required item", location=location, + items=item_pool[0], missing_item=missing_item, entry=i): + state = self._get_items_partial(item_pool, missing_item) + + self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False, + f"failed {self.multiworld.get_location(location, 1)}: succeeded with " + f"{missing_item} removed from: {item_pool}") + + def run_entrance_tests(self, access_pool): + for i, (entrance, access, *item_pool) in enumerate(access_pool): + items = item_pool[0] + all_except = item_pool[1] if len(item_pool) > 1 else None + state = self._get_items(item_pool, all_except) + path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region) + with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items, + all_except=all_except, path=path, entry=i): + + self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access) + + # check for partial solution + if not all_except and access: # we are not supposed to be able to reach location with partial inventory + for missing_item in item_pool[0]: + with self.subTest(msg="Entrance reachable without required item", entrance=entrance, + items=item_pool[0], missing_item=missing_item, entry=i): + state = self._get_items_partial(item_pool, missing_item) + self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False, + f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}") + + def _get_items(self, item_pool, all_except): + if all_except and len(all_except) > 0: + items = self.multiworld.itempool[:] + items = [item for item in items if + item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)] + items.extend(ItemFactory(item_pool[0], 1)) + else: + items = ItemFactory(item_pool[0], 1) + return self.get_state(items) + + def _get_items_partial(self, item_pool, missing_item): + new_items = item_pool[0].copy() + new_items.remove(missing_item) + items = ItemFactory(new_items, 1) + return self.get_state(items) + + +class WorldTestBase(unittest.TestCase): + options: typing.Dict[str, typing.Any] = {} + multiworld: MultiWorld + + game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore" + auto_construct: typing.ClassVar[bool] = True + """ automatically set up a world for each test in this class """ + + def setUp(self) -> None: + if self.auto_construct: + self.world_setup() + + def world_setup(self, seed: typing.Optional[int] = None) -> None: + if type(self) is WorldTestBase or \ + (hasattr(WorldTestBase, self._testMethodName) + and not self.run_default_tests and + getattr(self, self._testMethodName).__code__ is + getattr(WorldTestBase, self._testMethodName, None).__code__): + return # setUp gets called for tests defined in the base class. We skip world_setup here. + if not hasattr(self, "game"): + raise NotImplementedError("didn't define game name") + self.multiworld = MultiWorld(1) + self.multiworld.game[1] = self.game + self.multiworld.player_name = {1: "Tester"} + self.multiworld.set_seed(seed) + self.multiworld.state = CollectionState(self.multiworld) + args = Namespace() + for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items(): + setattr(args, name, { + 1: option.from_any(self.options.get(name, getattr(option, "default"))) + }) + self.multiworld.set_options(args) + for step in gen_steps: + call_all(self.multiworld, step) + + # methods that can be called within tests + def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]], + state: typing.Optional[CollectionState] = None) -> None: + """Collects all pre-placed items and items in the multiworld itempool except those provided""" + if isinstance(item_names, str): + item_names = (item_names,) + if not state: + state = self.multiworld.state + for item in self.multiworld.get_items(): + if item.name not in item_names: + state.collect(item) + + def get_item_by_name(self, item_name: str) -> Item: + """Returns the first item found in placed items, or in the itempool with the matching name""" + for item in self.multiworld.get_items(): + if item.name == item_name: + return item + raise ValueError("No such item") + + def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """Returns actual items from the itempool that match the provided name(s)""" + if isinstance(item_names, str): + item_names = (item_names,) + return [item for item in self.multiworld.itempool if item.name in item_names] + + def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """ collect all of the items in the item pool that have the given names """ + items = self.get_items_by_name(item_names) + self.collect(items) + return items + + def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: + """Collects the provided item(s) into state""" + if isinstance(items, Item): + items = (items,) + for item in items: + self.multiworld.state.collect(item) + + def remove_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]: + """Remove all of the items in the item pool with the given names from state""" + items = self.get_items_by_name(item_names) + self.remove(items) + return items + + def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None: + """Removes the provided item(s) from state""" + if isinstance(items, Item): + items = (items,) + for item in items: + if item.location and item.location.event and item.location in self.multiworld.state.events: + self.multiworld.state.events.remove(item.location) + self.multiworld.state.remove(item) + + def can_reach_location(self, location: str) -> bool: + """Determines if the current state can reach the provided location name""" + return self.multiworld.state.can_reach(location, "Location", 1) + + def can_reach_entrance(self, entrance: str) -> bool: + """Determines if the current state can reach the provided entrance name""" + return self.multiworld.state.can_reach(entrance, "Entrance", 1) + + def can_reach_region(self, region: str) -> bool: + """Determines if the current state can reach the provided region name""" + return self.multiworld.state.can_reach(region, "Region", 1) + + def count(self, item_name: str) -> int: + """Returns the amount of an item currently in state""" + return self.multiworld.state.count(item_name, 1) + + def assertAccessDependency(self, + locations: typing.List[str], + possible_items: typing.Iterable[typing.Iterable[str]], + only_check_listed: bool = False) -> None: + """Asserts that the provided locations can't be reached without the listed items but can be reached with any + one of the provided combinations""" + all_items = [item_name for item_names in possible_items for item_name in item_names] + + state = CollectionState(self.multiworld) + self.collect_all_but(all_items, state) + if only_check_listed: + for location in locations: + self.assertFalse(state.can_reach(location, "Location", 1), f"{location} is reachable without {all_items}") + else: + for location in self.multiworld.get_locations(): + loc_reachable = state.can_reach(location, "Location", 1) + self.assertEqual(loc_reachable, location.name not in locations, + f"{location.name} is reachable without {all_items}" if loc_reachable + else f"{location.name} is not reachable without {all_items}") + for item_names in possible_items: + items = self.get_items_by_name(item_names) + for item in items: + state.collect(item) + for location in locations: + self.assertTrue(state.can_reach(location, "Location", 1), + f"{location} not reachable with {item_names}") + for item in items: + state.remove(item) + + def assertBeatable(self, beatable: bool): + """Asserts that the game can be beaten with the current state""" + self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable) + + # following tests are automatically run + @property + def run_default_tests(self) -> bool: + """Not possible or identical to the base test that's always being run already""" + return (self.options + or self.setUp.__code__ is not WorldTestBase.setUp.__code__ + or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__) + + @property + def constructed(self) -> bool: + """A multiworld has been constructed by this point""" + return hasattr(self, "game") and hasattr(self, "multiworld") + + def test_all_state_can_reach_everything(self): + """Ensure all state can reach everything and complete the game with the defined options""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + excluded = self.multiworld.exclude_locations[1].value + state = self.multiworld.get_all_state(False) + for location in self.multiworld.get_locations(): + if location.name not in excluded: + with self.subTest("Location should be reached", location=location): + reachable = location.can_reach(state) + self.assertTrue(reachable, f"{location.name} unreachable") + with self.subTest("Beatable"): + self.multiworld.state = state + self.assertBeatable(True) + + def test_empty_state_can_reach_something(self): + """Ensure empty state can reach at least one location with the defined options""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + state = CollectionState(self.multiworld) + locations = self.multiworld.get_reachable_locations(state, 1) + self.assertGreater(len(locations), 0, + "Need to be able to reach at least one location to get started.") + + def test_fill(self): + """Generates a multiworld and validates placements with the defined options""" + if not (self.run_default_tests and self.constructed): + return + from Fill import distribute_items_restrictive + + # basically a shortened reimplementation of this method from core, in order to force the check is done + def fulfills_accessibility() -> bool: + locations = self.multiworld.get_locations(1).copy() + state = CollectionState(self.multiworld) + while locations: + sphere: typing.List[Location] = [] + for n in range(len(locations) - 1, -1, -1): + if locations[n].can_reach(state): + sphere.append(locations.pop(n)) + self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal", + f"Unreachable locations: {locations}") + if not sphere: + break + for location in sphere: + if location.item: + state.collect(location.item, True, location) + return self.multiworld.has_beaten_game(state, 1) + + with self.subTest("Game", game=self.game, seed=self.multiworld.seed): + distribute_items_restrictive(self.multiworld) + call_all(self.multiworld, "post_fill") + self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.") + placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code] + self.assertLessEqual(len(self.multiworld.itempool), len(placed_items), + "Unplaced Items remaining in itempool") diff --git a/test/general/__init__.py b/test/general/__init__.py index b0fb7ca32e76..5e0f22f4ecfa 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -1,22 +1,29 @@ from argparse import Namespace from typing import Type, Tuple -from BaseClasses import MultiWorld +from BaseClasses import MultiWorld, CollectionState from worlds.AutoWorld import call_all, World gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill") def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_steps) -> MultiWorld: + """ + Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps. + + :param world_type: Type of the world to generate a multiworld for + :param steps: The gen steps that should be called on the generated multiworld before returning. Default calls + steps through pre_fill + """ multiworld = MultiWorld(1) multiworld.game[1] = world_type.game multiworld.player_name = {1: "Tester"} multiworld.set_seed() + multiworld.state = CollectionState(multiworld) args = Namespace() - for name, option in world_type.option_definitions.items(): + for name, option in world_type.options_dataclass.type_hints.items(): setattr(args, name, {1: option.from_any(option.default)}) multiworld.set_options(args) - multiworld.set_default_common_options() for step in steps: call_all(multiworld, step) return multiworld diff --git a/test/general/TestFill.py b/test/general/test_fill.py similarity index 89% rename from test/general/TestFill.py rename to test/general/test_fill.py index 99f48cd0c70f..4e8cc2edb7c5 100644 --- a/test/general/TestFill.py +++ b/test/general/test_fill.py @@ -1,16 +1,20 @@ from typing import List, Iterable import unittest + +import Options +from Options import Accessibility from worlds.AutoWorld import World from Fill import FillError, balance_multiworld_progression, fill_restrictive, \ distribute_early_items, distribute_items_restrictive from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, Item, Location, \ - ItemClassification + ItemClassification, CollectionState from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule def generate_multi_world(players: int = 1) -> MultiWorld: multi_world = MultiWorld(players) multi_world.player_name = {} + multi_world.state = CollectionState(multi_world) for i in range(players): player_id = i+1 world = World(multi_world, player_id) @@ -19,9 +23,16 @@ def generate_multi_world(players: int = 1) -> MultiWorld: multi_world.player_name[player_id] = "Test Player " + str(player_id) region = Region("Menu", player_id, multi_world, "Menu Region Hint") multi_world.regions.append(region) + for option_key, option in Options.PerGameCommonOptions.type_hints.items(): + if hasattr(multi_world, option_key): + getattr(multi_world, option_key).setdefault(player_id, option.from_any(getattr(option, "default"))) + else: + setattr(multi_world, option_key, {player_id: option.from_any(getattr(option, "default"))}) + # TODO - remove this loop once all worlds use options dataclasses + world.options = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id] + for option_key in world.options_dataclass.type_hints}) multi_world.set_seed(0) - multi_world.set_default_common_options() return multi_world @@ -61,7 +72,7 @@ def generate_region(self, parent: Region, size: int, access_rule: CollectionRule return region -def fillRegion(world: MultiWorld, region: Region, items: List[Item]) -> List[Item]: +def fill_region(world: MultiWorld, region: Region, items: List[Item]) -> List[Item]: items = items.copy() while len(items) > 0: location = region.locations.pop(0) @@ -75,7 +86,7 @@ def fillRegion(world: MultiWorld, region: Region, items: List[Item]) -> List[Ite return items -def regionContains(region: Region, item: Item) -> bool: +def region_contains(region: Region, item: Item) -> bool: for location in region.locations: if location.item == item: return True @@ -122,6 +133,7 @@ def names(objs: list) -> Iterable[str]: class TestFillRestrictive(unittest.TestCase): def test_basic_fill(self): + """Tests `fill_restrictive` fills and removes the locations and items from their respective lists""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -139,6 +151,7 @@ def test_basic_fill(self): self.assertEqual([], player1.prog_items) def test_ordered_fill(self): + """Tests `fill_restrictive` fulfills set rules""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) items = player1.prog_items @@ -155,6 +168,7 @@ def test_ordered_fill(self): self.assertEqual(locations[1].item, items[1]) def test_partial_fill(self): + """Tests that `fill_restrictive` returns unfilled locations""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 3, 2) @@ -180,13 +194,14 @@ def test_partial_fill(self): self.assertEqual(player1.locations[0], loc2) def test_minimal_fill(self): + """Test that fill for minimal player can have unreachable items""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) items = player1.prog_items locations = player1.locations - multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal + multi_world.worlds[player1.id].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) multi_world.completion_condition[player1.id] = lambda state: state.has( items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( @@ -235,6 +250,7 @@ def test_minimal_mixed_fill(self): f'{item} is unreachable in {item.location}') def test_reversed_fill(self): + """Test a different set of rules can be satisfied""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -253,6 +269,7 @@ def test_reversed_fill(self): self.assertEqual(loc1.item, item0) def test_multi_step_fill(self): + """Test that fill is able to satisfy multiple spheres""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 4, 4) @@ -277,6 +294,7 @@ def test_multi_step_fill(self): self.assertEqual(locations[3].item, items[3]) def test_impossible_fill(self): + """Test that fill raises an error when it can't place any items""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) items = player1.prog_items @@ -293,6 +311,7 @@ def test_impossible_fill(self): player1.locations.copy(), player1.prog_items.copy()) def test_circular_fill(self): + """Test that fill raises an error when it can't place all items""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 3, 3) @@ -313,6 +332,7 @@ def test_circular_fill(self): player1.locations.copy(), player1.prog_items.copy()) def test_competing_fill(self): + """Test that fill raises an error when it can't place items in a way to satisfy the conditions""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -329,6 +349,7 @@ def test_competing_fill(self): player1.locations.copy(), player1.prog_items.copy()) def test_multiplayer_fill(self): + """Test that items can be placed across worlds""" multi_world = generate_multi_world(2) player1 = generate_player_data(multi_world, 1, 2, 2) player2 = generate_player_data(multi_world, 2, 2, 2) @@ -349,6 +370,7 @@ def test_multiplayer_fill(self): self.assertEqual(player2.locations[1].item, player2.prog_items[0]) def test_multiplayer_rules_fill(self): + """Test that fill across worlds satisfies the rules""" multi_world = generate_multi_world(2) player1 = generate_player_data(multi_world, 1, 2, 2) player2 = generate_player_data(multi_world, 2, 2, 2) @@ -372,6 +394,7 @@ def test_multiplayer_rules_fill(self): self.assertEqual(player2.locations[1].item, player1.prog_items[1]) def test_restrictive_progress(self): + """Test that various spheres with different requirements can be filled""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, prog_item_count=25) items = player1.prog_items.copy() @@ -394,6 +417,7 @@ def test_restrictive_progress(self): locations, player1.prog_items) def test_swap_to_earlier_location_with_item_rule(self): + """Test that item swap happens and works as intended""" # test for PR#1109 multi_world = generate_multi_world(1) player1 = generate_player_data(multi_world, 1, 4, 4) @@ -419,6 +443,7 @@ def test_swap_to_earlier_location_with_item_rule(self): self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1") def test_double_sweep(self): + """Test that sweep doesn't duplicate Event items when sweeping""" # test for PR1114 multi_world = generate_multi_world(1) player1 = generate_player_data(multi_world, 1, 1, 1) @@ -434,6 +459,7 @@ def test_double_sweep(self): self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times") def test_correct_item_instance_removed_from_pool(self): + """Test that a placed item gets removed from the submitted pool""" multi_world = generate_multi_world() player1 = generate_player_data(multi_world, 1, 2, 2) @@ -450,6 +476,7 @@ def test_correct_item_instance_removed_from_pool(self): class TestDistributeItemsRestrictive(unittest.TestCase): def test_basic_distribute(self): + """Test that distribute_items_restrictive is deterministic""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -469,6 +496,7 @@ def test_basic_distribute(self): self.assertFalse(locations[3].event) def test_excluded_distribute(self): + """Test that distribute_items_restrictive doesn't put advancement items on excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -483,6 +511,7 @@ def test_excluded_distribute(self): self.assertFalse(locations[2].item.advancement) def test_non_excluded_item_distribute(self): + """Test that useful items aren't placed on excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -497,6 +526,7 @@ def test_non_excluded_item_distribute(self): self.assertEqual(locations[1].item, basic_items[0]) def test_too_many_excluded_distribute(self): + """Test that fill fails if it can't place all progression items due to too many excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -509,6 +539,7 @@ def test_too_many_excluded_distribute(self): self.assertRaises(FillError, distribute_items_restrictive, multi_world) def test_non_excluded_item_must_distribute(self): + """Test that fill fails if it can't place useful items due to too many excluded locations""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -523,6 +554,7 @@ def test_non_excluded_item_must_distribute(self): self.assertRaises(FillError, distribute_items_restrictive, multi_world) def test_priority_distribute(self): + """Test that priority locations receive advancement items""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -537,6 +569,7 @@ def test_priority_distribute(self): self.assertTrue(locations[3].item.advancement) def test_excess_priority_distribute(self): + """Test that if there's more priority locations than advancement items, they can still fill""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -551,6 +584,7 @@ def test_excess_priority_distribute(self): self.assertFalse(locations[3].item.advancement) def test_multiple_world_priority_distribute(self): + """Test that priority fill can be satisfied for multiple worlds""" multi_world = generate_multi_world(3) player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -580,7 +614,7 @@ def test_multiple_world_priority_distribute(self): self.assertTrue(player3.locations[3].item.advancement) def test_can_remove_locations_in_fill_hook(self): - + """Test that distribute_items_restrictive calls the fill hook and allows for item and location removal""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, 4, prog_item_count=2, basic_item_count=2) @@ -600,6 +634,7 @@ def fill_hook(progitempool, usefulitempool, filleritempool, fill_locations): self.assertIsNone(removed_location[0].item) def test_seed_robust_to_item_order(self): + """Test deterministic fill""" mw1 = generate_multi_world() gen1 = generate_player_data( mw1, 1, 4, prog_item_count=2, basic_item_count=2) @@ -617,6 +652,7 @@ def test_seed_robust_to_item_order(self): self.assertEqual(gen1.locations[3].item, gen2.locations[3].item) def test_seed_robust_to_location_order(self): + """Test deterministic fill even if locations in a region are reordered""" mw1 = generate_multi_world() gen1 = generate_player_data( mw1, 1, 4, prog_item_count=2, basic_item_count=2) @@ -635,6 +671,7 @@ def test_seed_robust_to_location_order(self): self.assertEqual(gen1.locations[3].item, gen2.locations[3].item) def test_can_reserve_advancement_items_for_general_fill(self): + """Test that priority locations fill still satisfies item rules""" multi_world = generate_multi_world() player1 = generate_player_data( multi_world, 1, location_count=5, prog_item_count=5) @@ -644,14 +681,14 @@ def test_can_reserve_advancement_items_for_general_fill(self): location = player1.locations[0] location.progress_type = LocationProgressType.PRIORITY - location.item_rule = lambda item: item != items[ - 0] and item != items[1] and item != items[2] and item != items[3] + location.item_rule = lambda item: item not in items[:4] distribute_items_restrictive(multi_world) self.assertEqual(location.item, items[4]) def test_non_excluded_local_items(self): + """Test that local items get placed locally in a multiworld""" multi_world = generate_multi_world(2) player1 = generate_player_data( multi_world, 1, location_count=5, basic_item_count=5) @@ -672,6 +709,7 @@ def test_non_excluded_local_items(self): self.assertFalse(item.location.event, False) def test_early_items(self) -> None: + """Test that the early items API successfully places items early""" mw = generate_multi_world(2) player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5) player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5) @@ -751,21 +789,22 @@ def setUp(self) -> None: # Sphere 1 region = player1.generate_region(player1.menu, 20) - items = fillRegion(multi_world, region, [ + items = fill_region(multi_world, region, [ player1.prog_items[0]] + items) # Sphere 2 region = player1.generate_region( player1.regions[1], 20, lambda state: state.has(player1.prog_items[0].name, player1.id)) - items = fillRegion( + items = fill_region( multi_world, region, [player1.prog_items[1], player2.prog_items[0]] + items) # Sphere 3 region = player2.generate_region( player2.menu, 20, lambda state: state.has(player2.prog_items[0].name, player2.id)) - fillRegion(multi_world, region, [player2.prog_items[1]] + items) + fill_region(multi_world, region, [player2.prog_items[1]] + items) def test_balances_progression(self) -> None: + """Tests that progression balancing moves progression items earlier""" self.multi_world.progression_balancing[self.player1.id].value = 50 self.multi_world.progression_balancing[self.player2.id].value = 50 @@ -778,6 +817,7 @@ def test_balances_progression(self) -> None: self.player1.regions[1], self.player2.prog_items[0]) def test_balances_progression_light(self) -> None: + """Test that progression balancing still moves items earlier on minimum value""" self.multi_world.progression_balancing[self.player1.id].value = 1 self.multi_world.progression_balancing[self.player2.id].value = 1 @@ -791,6 +831,7 @@ def test_balances_progression_light(self) -> None: self.player1.regions[1], self.player2.prog_items[0]) def test_balances_progression_heavy(self) -> None: + """Test that progression balancing moves items earlier on maximum value""" self.multi_world.progression_balancing[self.player1.id].value = 99 self.multi_world.progression_balancing[self.player2.id].value = 99 @@ -804,6 +845,7 @@ def test_balances_progression_heavy(self) -> None: self.player1.regions[1], self.player2.prog_items[0]) def test_skips_balancing_progression(self) -> None: + """Test that progression balancing is skipped when players have it disabled""" self.multi_world.progression_balancing[self.player1.id].value = 0 self.multi_world.progression_balancing[self.player2.id].value = 0 @@ -816,6 +858,7 @@ def test_skips_balancing_progression(self) -> None: self.player1.regions[2], self.player2.prog_items[0]) def test_ignores_priority_locations(self) -> None: + """Test that progression items on priority locations don't get moved by balancing""" self.multi_world.progression_balancing[self.player1.id].value = 50 self.multi_world.progression_balancing[self.player2.id].value = 50 diff --git a/test/general/TestHelpers.py b/test/general/test_helpers.py similarity index 90% rename from test/general/TestHelpers.py rename to test/general/test_helpers.py index c0b560c7e4a6..83b56b34386b 100644 --- a/test/general/TestHelpers.py +++ b/test/general/test_helpers.py @@ -1,7 +1,7 @@ -from typing import Dict, Optional, Callable - -from BaseClasses import MultiWorld, CollectionState, Region import unittest +from typing import Callable, Dict, Optional + +from BaseClasses import CollectionState, MultiWorld, Region class TestHelpers(unittest.TestCase): @@ -13,9 +13,9 @@ def setUp(self) -> None: self.multiworld.game[self.player] = "helper_test_game" self.multiworld.player_name = {1: "Tester"} self.multiworld.set_seed() - self.multiworld.set_default_common_options() - def testRegionHelpers(self) -> None: + def test_region_helpers(self) -> None: + """Tests `Region.add_locations()` and `Region.add_exits()` have correct behavior""" regions: Dict[str, str] = { "TestRegion1": "I'm an apple", "TestRegion2": "I'm a banana", @@ -79,4 +79,5 @@ def testRegionHelpers(self) -> None: current_region.add_exits(reg_exit_set[region]) exit_names = {_exit.name for _exit in current_region.exits} for reg_exit in reg_exit_set[region]: - self.assertTrue(f"{region} -> {reg_exit}" in exit_names, f"{region} -> {reg_exit} not in {exit_names}") + self.assertTrue(f"{region} -> {reg_exit}" in exit_names, + f"{region} -> {reg_exit} not in {exit_names}") diff --git a/test/general/TestHostYAML.py b/test/general/test_host_yaml.py similarity index 87% rename from test/general/TestHostYAML.py rename to test/general/test_host_yaml.py index f5fd406cac84..9408f95b1658 100644 --- a/test/general/TestHostYAML.py +++ b/test/general/test_host_yaml.py @@ -15,6 +15,7 @@ def setUpClass(cls) -> None: cls.yaml_options = Utils.parse_yaml(f.read()) def test_utils_in_yaml(self) -> None: + """Tests that the auto generated host.yaml has default settings in it""" for option_key, option_set in Utils.get_default_options().items(): with self.subTest(option_key): self.assertIn(option_key, self.yaml_options) @@ -22,6 +23,7 @@ def test_utils_in_yaml(self) -> None: self.assertIn(sub_option_key, self.yaml_options[option_key]) def test_yaml_in_utils(self) -> None: + """Tests that the auto generated host.yaml shows up in reference calls""" utils_options = Utils.get_default_options() for option_key, option_set in self.yaml_options.items(): with self.subTest(option_key): diff --git a/test/general/TestIDs.py b/test/general/test_ids.py similarity index 82% rename from test/general/TestIDs.py rename to test/general/test_ids.py index db1c9461b91a..4edfb8d994ef 100644 --- a/test/general/TestIDs.py +++ b/test/general/test_ids.py @@ -3,35 +3,37 @@ class TestIDs(unittest.TestCase): - def testUniqueItems(self): + def test_unique_items(self): + """Tests that every game has a unique ID per item in the datapackage""" known_item_ids = set() for gamename, world_type in AutoWorldRegister.world_types.items(): current = len(known_item_ids) known_item_ids |= set(world_type.item_id_to_name) self.assertEqual(len(known_item_ids) - len(world_type.item_id_to_name), current) - def testUniqueLocations(self): + def test_unique_locations(self): + """Tests that every game has a unique ID per location in the datapackage""" known_location_ids = set() for gamename, world_type in AutoWorldRegister.world_types.items(): current = len(known_location_ids) known_location_ids |= set(world_type.location_id_to_name) self.assertEqual(len(known_location_ids) - len(world_type.location_id_to_name), current) - def testRangeItems(self): + def test_range_items(self): """There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): for item_id in world_type.item_id_to_name: self.assertLess(item_id, 2**53) - def testRangeLocations(self): + def test_range_locations(self): """There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): for location_id in world_type.location_id_to_name: self.assertLess(location_id, 2**53) - def testReservedItems(self): + def test_reserved_items(self): """negative item IDs are reserved to the special "Archipelago" world.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): @@ -42,7 +44,7 @@ def testReservedItems(self): for item_id in world_type.item_id_to_name: self.assertGreater(item_id, 0) - def testReservedLocations(self): + def test_reserved_locations(self): """negative location IDs are reserved to the special "Archipelago" world.""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): @@ -53,12 +55,14 @@ def testReservedLocations(self): for location_id in world_type.location_id_to_name: self.assertGreater(location_id, 0) - def testDuplicateItemIDs(self): + def test_duplicate_item_ids(self): + """Test that a game doesn't have item id overlap within its own datapackage""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id)) - def testDuplicateLocationIDs(self): + def test_duplicate_location_ids(self): + """Test that a game doesn't have location id overlap within its own datapackage""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id)) diff --git a/test/general/TestImplemented.py b/test/general/test_implemented.py similarity index 93% rename from test/general/TestImplemented.py rename to test/general/test_implemented.py index 22c546eff18b..67d0e5ff72f0 100644 --- a/test/general/TestImplemented.py +++ b/test/general/test_implemented.py @@ -5,7 +5,7 @@ class TestImplemented(unittest.TestCase): - def testCompletionCondition(self): + def test_completion_condition(self): """Ensure a completion condition is set that has requirements.""" for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden and game_name not in {"Sudoku"}: @@ -13,7 +13,7 @@ def testCompletionCondition(self): multiworld = setup_solo_multiworld(world_type) self.assertFalse(multiworld.completion_condition[1](multiworld.state)) - def testEntranceParents(self): + def test_entrance_parents(self): """Tests that the parents of created Entrances match the exiting Region.""" for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: @@ -23,7 +23,7 @@ def testEntranceParents(self): for exit in region.exits: self.assertEqual(exit.parent_region, region) - def testStageMethods(self): + def test_stage_methods(self): """Tests that worlds don't try to implement certain steps that are only ever called as stage.""" for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: diff --git a/test/general/TestItems.py b/test/general/test_items.py similarity index 88% rename from test/general/TestItems.py rename to test/general/test_items.py index 95eb8d28d9af..464d246e1fa3 100644 --- a/test/general/TestItems.py +++ b/test/general/test_items.py @@ -4,7 +4,8 @@ class TestBase(unittest.TestCase): - def testCreateItem(self): + def test_create_item(self): + """Test that a world can successfully create all items in its datapackage""" for game_name, world_type in AutoWorldRegister.world_types.items(): proxy_world = world_type(None, 0) # this is identical to MultiServer.py creating worlds for item_name in world_type.item_name_to_id: @@ -12,7 +13,7 @@ def testCreateItem(self): item = proxy_world.create_item(item_name) self.assertEqual(item.name, item_name) - def testItemNameGroupHasValidItem(self): + def test_item_name_group_has_valid_item(self): """Test that all item name groups contain valid items. """ # This cannot test for Event names that you may have declared for logic, only sendable Items. # In such a case, you can add your entries to this Exclusion dict. Game Name -> Group Names @@ -33,7 +34,7 @@ def testItemNameGroupHasValidItem(self): for item in items: self.assertIn(item, world_type.item_name_to_id) - def testItemNameGroupConflict(self): + def test_item_name_group_conflict(self): """Test that all item name groups aren't also item names.""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game_name, game_name=game_name): @@ -41,7 +42,8 @@ def testItemNameGroupConflict(self): with self.subTest(group_name, group_name=group_name): self.assertNotIn(group_name, world_type.item_name_to_id) - def testItemCountGreaterEqualLocations(self): + def test_item_count_greater_equal_locations(self): + """Test that by the pre_fill step under default settings, each game submits items >= locations""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): multiworld = setup_solo_multiworld(world_type) diff --git a/test/general/TestLocations.py b/test/general/test_locations.py similarity index 96% rename from test/general/TestLocations.py rename to test/general/test_locations.py index e77e7a6332bb..2e609a756f09 100644 --- a/test/general/TestLocations.py +++ b/test/general/test_locations.py @@ -5,7 +5,7 @@ class TestBase(unittest.TestCase): - def testCreateDuplicateLocations(self): + def test_create_duplicate_locations(self): """Tests that no two Locations share a name or ID.""" for game_name, world_type in AutoWorldRegister.world_types.items(): multiworld = setup_solo_multiworld(world_type) @@ -20,7 +20,7 @@ def testCreateDuplicateLocations(self): self.assertLessEqual(locations.most_common(1)[0][1], 1, f"{world_type.game} has duplicate of location ID {locations.most_common(1)}") - def testLocationsInDatapackage(self): + def test_locations_in_datapackage(self): """Tests that created locations not filled before fill starts exist in the datapackage.""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game_name=game_name): @@ -30,7 +30,7 @@ def testLocationsInDatapackage(self): self.assertIn(location.name, world_type.location_name_to_id) self.assertEqual(location.address, world_type.location_name_to_id[location.name]) - def testLocationCreationSteps(self): + def test_location_creation_steps(self): """Tests that Regions and Locations aren't created after `create_items`.""" gen_steps = ("generate_early", "create_regions", "create_items") for game_name, world_type in AutoWorldRegister.world_types.items(): @@ -60,7 +60,7 @@ def testLocationCreationSteps(self): self.assertGreaterEqual(location_count, len(multiworld.get_locations()), f"{game_name} modified locations count during pre_fill") - def testLocationGroup(self): + def test_location_group(self): """Test that all location name groups contain valid locations and don't share names.""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game_name, game_name=game_name): diff --git a/test/general/TestNames.py b/test/general/test_names.py similarity index 92% rename from test/general/TestNames.py rename to test/general/test_names.py index 6dae53240d10..7be76eed4ba9 100644 --- a/test/general/TestNames.py +++ b/test/general/test_names.py @@ -3,7 +3,7 @@ class TestNames(unittest.TestCase): - def testItemNamesFormat(self): + def test_item_names_format(self): """Item names must not be all numeric in order to differentiate between ID and name in !hint""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): @@ -11,7 +11,7 @@ def testItemNamesFormat(self): self.assertFalse(item_name.isnumeric(), f"Item name \"{item_name}\" is invalid. It must not be numeric.") - def testLocationNameFormat(self): + def test_location_name_format(self): """Location names must not be all numeric in order to differentiate between ID and name in !hint_location""" for gamename, world_type in AutoWorldRegister.world_types.items(): with self.subTest(game=gamename): diff --git a/test/general/TestOptions.py b/test/general/test_options.py similarity index 61% rename from test/general/TestOptions.py rename to test/general/test_options.py index b7058183e09c..e1136f93c96f 100644 --- a/test/general/TestOptions.py +++ b/test/general/test_options.py @@ -3,9 +3,10 @@ class TestOptions(unittest.TestCase): - def testOptionsHaveDocString(self): + def test_options_have_doc_string(self): + """Test that submitted options have their own specified docstring""" for gamename, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: - for option_key, option in world_type.option_definitions.items(): + for option_key, option in world_type.options_dataclass.type_hints.items(): with self.subTest(game=gamename, option=option_key): self.assertTrue(option.__doc__) diff --git a/test/general/TestReachability.py b/test/general/test_reachability.py similarity index 91% rename from test/general/TestReachability.py rename to test/general/test_reachability.py index dd786b8352f5..828912ee35a3 100644 --- a/test/general/TestReachability.py +++ b/test/general/test_reachability.py @@ -31,7 +31,8 @@ class TestBase(unittest.TestCase): } } - def testDefaultAllStateCanReachEverything(self): + def test_default_all_state_can_reach_everything(self): + """Ensure all state can reach everything and complete the game with the defined options""" for game_name, world_type in AutoWorldRegister.world_types.items(): unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set()) with self.subTest("Game", game=game_name): @@ -54,7 +55,8 @@ def testDefaultAllStateCanReachEverything(self): with self.subTest("Completion Condition"): self.assertTrue(world.can_beat_game(state)) - def testDefaultEmptyStateCanReachSomething(self): + def test_default_empty_state_can_reach_something(self): + """Ensure empty state can reach at least one location with the defined options""" for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): world = setup_solo_multiworld(world_type) diff --git a/test/netutils/TestLocationStore.py b/test/netutils/test_location_store.py similarity index 100% rename from test/netutils/TestLocationStore.py rename to test/netutils/test_location_store.py diff --git a/test/programs/data/OnePlayer/test.yaml b/test/programs/data/one_player/test.yaml similarity index 100% rename from test/programs/data/OnePlayer/test.yaml rename to test/programs/data/one_player/test.yaml diff --git a/test/programs/TestGenerate.py b/test/programs/test_generate.py similarity index 96% rename from test/programs/TestGenerate.py rename to test/programs/test_generate.py index d04e1f2c5bf4..887a417ec9f9 100644 --- a/test/programs/TestGenerate.py +++ b/test/programs/test_generate.py @@ -1,13 +1,13 @@ # Tests for Generate.py (ArchipelagoGenerate.exe) import unittest +import os +import os.path import sys + from pathlib import Path from tempfile import TemporaryDirectory -import os.path -import os -import ModuleUpdate -ModuleUpdate.update_ran = True # don't upgrade + import Generate @@ -16,7 +16,7 @@ class TestGenerateMain(unittest.TestCase): generate_dir = Path(Generate.__file__).parent run_dir = generate_dir / "test" # reproducible cwd that's neither __file__ nor Generate.__file__ - abs_input_dir = Path(__file__).parent / 'data' / 'OnePlayer' + abs_input_dir = Path(__file__).parent / 'data' / 'one_player' rel_input_dir = abs_input_dir.relative_to(run_dir) # directly supplied relative paths are relative to cwd yaml_input_dir = abs_input_dir.relative_to(generate_dir) # yaml paths are relative to user_path diff --git a/test/programs/TestMultiServer.py b/test/programs/test_multi_server.py similarity index 100% rename from test/programs/TestMultiServer.py rename to test/programs/test_multi_server.py diff --git a/test/utils/TestSIPrefix.py b/test/utils/test_si_prefix.py similarity index 100% rename from test/utils/TestSIPrefix.py rename to test/utils/test_si_prefix.py diff --git a/test/webhost/TestAPIGenerate.py b/test/webhost/test_api_generate.py similarity index 87% rename from test/webhost/TestAPIGenerate.py rename to test/webhost/test_api_generate.py index 5c14373a90e6..b8bdcb38c764 100644 --- a/test/webhost/TestAPIGenerate.py +++ b/test/webhost/test_api_generate.py @@ -5,7 +5,8 @@ class TestDocs(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - from WebHost import get_app, raw_app + from WebHostLib import app as raw_app + from WebHost import get_app raw_app.config["PONY"] = { "provider": "sqlite", "filename": ":memory:", @@ -18,11 +19,11 @@ def setUpClass(cls) -> None: cls.client = app.test_client() - def testCorrectErrorEmptyRequest(self): + def test_correct_error_empty_request(self): response = self.client.post("/api/generate") self.assertIn("No options found. Expected file attachment or json weights.", response.text) - def testGenerationQueued(self): + def test_generation_queued(self): options = { "Tester1": { diff --git a/test/webhost/TestDocs.py b/test/webhost/test_docs.py similarity index 96% rename from test/webhost/TestDocs.py rename to test/webhost/test_docs.py index f6ede1543e26..68aba05f9dcc 100644 --- a/test/webhost/TestDocs.py +++ b/test/webhost/test_docs.py @@ -11,7 +11,7 @@ class TestDocs(unittest.TestCase): def setUpClass(cls) -> None: cls.tutorials_data = WebHost.create_ordered_tutorials_file() - def testHasTutorial(self): + def test_has_tutorial(self): games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data) for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: @@ -27,7 +27,7 @@ def testHasTutorial(self): self.fail(f"{game_name} has no setup tutorial. " f"Games with Tutorial: {games_with_tutorial}") - def testHasGameInfo(self): + def test_has_game_info(self): for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game_name) diff --git a/test/webhost/TestFileGeneration.py b/test/webhost/test_file_generation.py similarity index 90% rename from test/webhost/TestFileGeneration.py rename to test/webhost/test_file_generation.py index 6010202c4101..059f6b49a1fd 100644 --- a/test/webhost/TestFileGeneration.py +++ b/test/webhost/test_file_generation.py @@ -13,8 +13,9 @@ def setUpClass(cls) -> None: # should not create the folder *here* cls.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib") - def testOptions(self): - WebHost.create_options_files() + def test_options(self): + from WebHostLib.options import create as create_options_files + create_options_files() target = os.path.join(self.correct_path, "static", "generated", "configs") self.assertTrue(os.path.exists(target)) self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "configs"))) @@ -29,7 +30,7 @@ def testOptions(self): for value in roll_options({file.name: f.read()})[0].values(): self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.") - def testTutorial(self): + def test_tutorial(self): WebHost.create_ordered_tutorials_file() self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json"))) self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json"))) diff --git a/test/worlds/__init__.py b/test/worlds/__init__.py index d1817cc67489..cf396111bfd3 100644 --- a/test/worlds/__init__.py +++ b/test/worlds/__init__.py @@ -1,7 +1,7 @@ def load_tests(loader, standard_tests, pattern): import os import unittest - from ..TestBase import file_path + from .. import file_path from worlds.AutoWorld import AutoWorldRegister suite = unittest.TestSuite() diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 217269aa9927..9a8b6a56ef36 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -4,11 +4,12 @@ import logging import pathlib import sys -from typing import Any, Callable, ClassVar, Dict, FrozenSet, List, Optional, Set, TYPE_CHECKING, TextIO, Tuple, Type, \ +from dataclasses import make_dataclass +from typing import Any, Callable, ClassVar, Dict, Set, Tuple, FrozenSet, List, Optional, TYPE_CHECKING, TextIO, Type, \ Union +from Options import PerGameCommonOptions from BaseClasses import CollectionState -from Options import AssembleOptions if TYPE_CHECKING: import random @@ -63,6 +64,12 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut dct["required_client_version"] = max(dct["required_client_version"], base.__dict__["required_client_version"]) + # create missing options_dataclass from legacy option_definitions + # TODO - remove this once all worlds use options dataclasses + if "options_dataclass" not in dct and "option_definitions" in dct: + dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(), + bases=(PerGameCommonOptions,)) + # construct class new_class = super().__new__(mcs, name, bases, dct) if "game" in dct: @@ -163,8 +170,11 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - option_definitions: ClassVar[Dict[str, AssembleOptions]] = {} + options_dataclass: ClassVar[Type[PerGameCommonOptions]] = PerGameCommonOptions """link your Options mapping""" + options: PerGameCommonOptions + """resulting options for the player of this world""" + game: ClassVar[str] """name the game""" topology_present: ClassVar[bool] = False @@ -358,6 +368,19 @@ def get_filler_item_name(self) -> str: logging.warning(f"World {self} is generating a filler item without custom filler pool.") return self.multiworld.random.choice(tuple(self.item_name_to_id.keys())) + @classmethod + def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World: + """Creates a group, which is an instance of World that is responsible for multiple others. + An example case is ItemLinks creating these.""" + # TODO remove loop when worlds use options dataclass + for option_key, option in cls.options_dataclass.type_hints.items(): + getattr(multiworld, option_key)[new_player_id] = option(option.default) + group = cls(multiworld, new_player_id) + group.options = cls.options_dataclass(**{option_key: option(option.default) + for option_key, option in cls.options_dataclass.type_hints.items()}) + + return group + # decent place to implement progressive items, in most cases can stay as-is def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]: """Collect an item name into state. For speed reasons items that aren't logically useful get skipped. diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index c3ae2b0495b0..2d445a77b8e0 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -89,6 +89,9 @@ def launch_textclient(): Component('SNI Client', 'SNIClient', file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw', '.apl2ac')), + # BizHawk + Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, + file_identifier=SuffixIdentifier()), Component('Links Awakening DX Client', 'LinksAwakeningClient', file_identifier=SuffixIdentifier('.apladx')), Component('LttP Adjuster', 'LttPAdjuster'), diff --git a/worlds/_bizhawk/__init__.py b/worlds/_bizhawk/__init__.py new file mode 100644 index 000000000000..cdf227ec7bdc --- /dev/null +++ b/worlds/_bizhawk/__init__.py @@ -0,0 +1,326 @@ +""" +A module for interacting with BizHawk through `connector_bizhawk_generic.lua`. + +Any mention of `domain` in this module refers to the names BizHawk gives to memory domains in its own lua api. They are +naively passed to BizHawk without validation or modification. +""" + +import asyncio +import base64 +import enum +import json +import typing + + +BIZHAWK_SOCKET_PORT = 43055 +EXPECTED_SCRIPT_VERSION = 1 + + +class ConnectionStatus(enum.IntEnum): + NOT_CONNECTED = 1 + TENTATIVE = 2 + CONNECTED = 3 + + +class BizHawkContext: + streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]] + connection_status: ConnectionStatus + + def __init__(self) -> None: + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + + +class NotConnectedError(Exception): + """Raised when something tries to make a request to the connector script before a connection has been established""" + pass + + +class RequestFailedError(Exception): + """Raised when the connector script did not respond to a request""" + pass + + +class ConnectorError(Exception): + """Raised when the connector script encounters an error while processing a request""" + pass + + +class SyncError(Exception): + """Raised when the connector script responded with a mismatched response type""" + pass + + +async def connect(ctx: BizHawkContext) -> bool: + """Attempts to establish a connection with the connector script. Returns True if successful.""" + try: + ctx.streams = await asyncio.open_connection("localhost", BIZHAWK_SOCKET_PORT) + ctx.connection_status = ConnectionStatus.TENTATIVE + return True + except (TimeoutError, ConnectionRefusedError): + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + return False + + +def disconnect(ctx: BizHawkContext) -> None: + """Closes the connection to the connector script.""" + if ctx.streams is not None: + ctx.streams[1].close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + + +async def get_script_version(ctx: BizHawkContext) -> int: + if ctx.streams is None: + raise NotConnectedError("You tried to send a request before a connection to BizHawk was made") + + try: + reader, writer = ctx.streams + writer.write("VERSION".encode("ascii") + b"\n") + await asyncio.wait_for(writer.drain(), timeout=5) + + version = await asyncio.wait_for(reader.readline(), timeout=5) + + if version == b"": + writer.close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection closed") + + return int(version.decode("ascii")) + except asyncio.TimeoutError as exc: + writer.close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection timed out") from exc + except ConnectionResetError as exc: + writer.close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection reset") from exc + + +async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]: + """Sends a list of requests to the BizHawk connector and returns their responses. + + It's likely you want to use the wrapper functions instead of this.""" + if ctx.streams is None: + raise NotConnectedError("You tried to send a request before a connection to BizHawk was made") + + try: + reader, writer = ctx.streams + writer.write(json.dumps(req_list).encode("utf-8") + b"\n") + await asyncio.wait_for(writer.drain(), timeout=5) + + res = await asyncio.wait_for(reader.readline(), timeout=5) + + if res == b"": + writer.close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection closed") + + if ctx.connection_status == ConnectionStatus.TENTATIVE: + ctx.connection_status = ConnectionStatus.CONNECTED + + ret = json.loads(res.decode("utf-8")) + for response in ret: + if response["type"] == "ERROR": + raise ConnectorError(response["err"]) + + return ret + except asyncio.TimeoutError as exc: + writer.close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection timed out") from exc + except ConnectionResetError as exc: + writer.close() + ctx.streams = None + ctx.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection reset") from exc + + +async def ping(ctx: BizHawkContext) -> None: + """Sends a PING request and receives a PONG response.""" + res = (await send_requests(ctx, [{"type": "PING"}]))[0] + + if res["type"] != "PONG": + raise SyncError(f"Expected response of type PONG but got {res['type']}") + + +async def get_hash(ctx: BizHawkContext) -> str: + """Gets the system name for the currently loaded ROM""" + res = (await send_requests(ctx, [{"type": "HASH"}]))[0] + + if res["type"] != "HASH_RESPONSE": + raise SyncError(f"Expected response of type HASH_RESPONSE but got {res['type']}") + + return res["value"] + + +async def get_system(ctx: BizHawkContext) -> str: + """Gets the system name for the currently loaded ROM""" + res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0] + + if res["type"] != "SYSTEM_RESPONSE": + raise SyncError(f"Expected response of type SYSTEM_RESPONSE but got {res['type']}") + + return res["value"] + + +async def get_cores(ctx: BizHawkContext) -> typing.Dict[str, str]: + """Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have + entries.""" + res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0] + + if res["type"] != "PREFERRED_CORES_RESPONSE": + raise SyncError(f"Expected response of type PREFERRED_CORES_RESPONSE but got {res['type']}") + + return res["value"] + + +async def lock(ctx: BizHawkContext) -> None: + """Locks BizHawk in anticipation of receiving more requests this frame. + + Consider using guarded reads and writes instead of locks if possible. + + While locked, emulation will halt and the connector will block on incoming requests until an `UNLOCK` request is + sent. Remember to unlock when you're done, or the emulator will appear to freeze. + + Sending multiple lock commands is the same as sending one.""" + res = (await send_requests(ctx, [{"type": "LOCK"}]))[0] + + if res["type"] != "LOCKED": + raise SyncError(f"Expected response of type LOCKED but got {res['type']}") + + +async def unlock(ctx: BizHawkContext) -> None: + """Unlocks BizHawk to allow it to resume emulation. See `lock` for more info. + + Sending multiple unlock commands is the same as sending one.""" + res = (await send_requests(ctx, [{"type": "UNLOCK"}]))[0] + + if res["type"] != "UNLOCKED": + raise SyncError(f"Expected response of type UNLOCKED but got {res['type']}") + + +async def display_message(ctx: BizHawkContext, message: str) -> None: + """Displays the provided message in BizHawk's message queue.""" + res = (await send_requests(ctx, [{"type": "DISPLAY_MESSAGE", "message": message}]))[0] + + if res["type"] != "DISPLAY_MESSAGE_RESPONSE": + raise SyncError(f"Expected response of type DISPLAY_MESSAGE_RESPONSE but got {res['type']}") + + +async def set_message_interval(ctx: BizHawkContext, value: float) -> None: + """Sets the minimum amount of time in seconds to wait between queued messages. The default value of 0 will allow one + new message to display per frame.""" + res = (await send_requests(ctx, [{"type": "SET_MESSAGE_INTERVAL", "value": value}]))[0] + + if res["type"] != "SET_MESSAGE_INTERVAL_RESPONSE": + raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}") + + +async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]], + guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> typing.Optional[typing.List[bytes]]: + """Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected + value. + + Items in read_list should be organized (address, size, domain) where + - `address` is the address of the first byte of data + - `size` is the number of bytes to read + - `domain` is the name of the region of memory the address corresponds to + + Items in `guard_list` should be organized `(address, expected_data, domain)` where + - `address` is the address of the first byte of data + - `expected_data` is the bytes that the data starting at this address is expected to match + - `domain` is the name of the region of memory the address corresponds to + + Returns None if any item in guard_list failed to validate. Otherwise returns a list of bytes in the order they + were requested.""" + res = await send_requests(ctx, [{ + "type": "GUARD", + "address": address, + "expected_data": base64.b64encode(bytes(expected_data)).decode("ascii"), + "domain": domain + } for address, expected_data, domain in guard_list] + [{ + "type": "READ", + "address": address, + "size": size, + "domain": domain + } for address, size, domain in read_list]) + + ret: typing.List[bytes] = [] + for item in res: + if item["type"] == "GUARD_RESPONSE": + if not item["value"]: + return None + else: + if item["type"] != "READ_RESPONSE": + raise SyncError(f"Expected response of type READ_RESPONSE or GUARD_RESPONSE but got {res['type']}") + + ret.append(base64.b64decode(item["value"])) + + return ret + + +async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]]) -> typing.List[bytes]: + """Reads data at 1 or more addresses. + + Items in `read_list` should be organized `(address, size, domain)` where + - `address` is the address of the first byte of data + - `size` is the number of bytes to read + - `domain` is the name of the region of memory the address corresponds to + + Returns a list of bytes in the order they were requested.""" + return await guarded_read(ctx, read_list, []) + + +async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]], + guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> bool: + """Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value. + + Items in `write_list` should be organized `(address, value, domain)` where + - `address` is the address of the first byte of data + - `value` is a list of bytes to write, in order, starting at `address` + - `domain` is the name of the region of memory the address corresponds to + + Items in `guard_list` should be organized `(address, expected_data, domain)` where + - `address` is the address of the first byte of data + - `expected_data` is the bytes that the data starting at this address is expected to match + - `domain` is the name of the region of memory the address corresponds to + + Returns False if any item in guard_list failed to validate. Otherwise returns True.""" + res = await send_requests(ctx, [{ + "type": "GUARD", + "address": address, + "expected_data": base64.b64encode(bytes(expected_data)).decode("ascii"), + "domain": domain + } for address, expected_data, domain in guard_list] + [{ + "type": "WRITE", + "address": address, + "value": base64.b64encode(bytes(value)).decode("ascii"), + "domain": domain + } for address, value, domain in write_list]) + + for item in res: + if item["type"] == "GUARD_RESPONSE": + if not item["value"]: + return False + else: + if item["type"] != "WRITE_RESPONSE": + raise SyncError(f"Expected response of type WRITE_RESPONSE or GUARD_RESPONSE but got {res['type']}") + + return True + + +async def write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> None: + """Writes data to 1 or more addresses. + + Items in write_list should be organized `(address, value, domain)` where + - `address` is the address of the first byte of data + - `value` is a list of bytes to write, in order, starting at `address` + - `domain` is the name of the region of memory the address corresponds to""" + await guarded_write(ctx, write_list, []) diff --git a/worlds/_bizhawk/client.py b/worlds/_bizhawk/client.py new file mode 100644 index 000000000000..b614c083ba4e --- /dev/null +++ b/worlds/_bizhawk/client.py @@ -0,0 +1,87 @@ +""" +A module containing the BizHawkClient base class and metaclass +""" + + +from __future__ import annotations + +import abc +from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union + +from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess + +if TYPE_CHECKING: + from .context import BizHawkClientContext +else: + BizHawkClientContext = object + + +class AutoBizHawkClientRegister(abc.ABCMeta): + game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {} + + def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister: + new_class = super().__new__(cls, name, bases, namespace) + + if "system" in namespace: + systems = (namespace["system"],) if type(namespace["system"]) is str else tuple(sorted(namespace["system"])) + if systems not in AutoBizHawkClientRegister.game_handlers: + AutoBizHawkClientRegister.game_handlers[systems] = {} + + if "game" in namespace: + AutoBizHawkClientRegister.game_handlers[systems][namespace["game"]] = new_class() + + return new_class + + @staticmethod + async def get_handler(ctx: BizHawkClientContext, system: str) -> Optional[BizHawkClient]: + for systems, handlers in AutoBizHawkClientRegister.game_handlers.items(): + if system in systems: + for handler in handlers.values(): + if await handler.validate_rom(ctx): + return handler + + return None + + +class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister): + system: ClassVar[Union[str, Tuple[str, ...]]] + """The system that the game this client is for runs on""" + + game: ClassVar[str] + """The game this client is for""" + + @abc.abstractmethod + async def validate_rom(self, ctx: BizHawkClientContext) -> bool: + """Should return whether the currently loaded ROM should be handled by this client. You might read the game name + from the ROM header, for example. This function will only be asked to validate ROMs from the system set by the + client class, so you do not need to check the system yourself. + + Once this function has determined that the ROM should be handled by this client, it should also modify `ctx` + as necessary (such as setting `ctx.game = self.game`, modifying `ctx.items_handling`, etc...).""" + ... + + async def set_auth(self, ctx: BizHawkClientContext) -> None: + """Should set ctx.auth in anticipation of sending a `Connected` packet. You may override this if you store slot + name in your patched ROM. If ctx.auth is not set after calling, the player will be prompted to enter their + username.""" + pass + + @abc.abstractmethod + async def game_watcher(self, ctx: BizHawkClientContext) -> None: + """Runs on a loop with the approximate interval `ctx.watcher_timeout`. The currently loaded ROM is guaranteed + to have passed your validator when this function is called, and the emulator is very likely to be connected.""" + ... + + def on_package(self, ctx: BizHawkClientContext, cmd: str, args: dict) -> None: + """For handling packages from the server. Called from `BizHawkClientContext.on_package`.""" + pass + + +def launch_client(*args) -> None: + from .context import launch + launch_subprocess(launch, name="BizHawkClient") + + +if not any(component.script_name == "BizHawkClient" for component in components): + components.append(Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, + file_identifier=SuffixIdentifier())) diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py new file mode 100644 index 000000000000..465334274e8e --- /dev/null +++ b/worlds/_bizhawk/context.py @@ -0,0 +1,205 @@ +""" +A module containing context and functions relevant to running the client. This module should only be imported for type +checking or launching the client, otherwise it will probably cause circular import issues. +""" + + +import asyncio +import subprocess +import traceback +from typing import Any, Dict, Optional + +from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled +import Patch +import Utils + +from . import BizHawkContext, ConnectionStatus, RequestFailedError, connect, disconnect, get_hash, get_script_version, \ + get_system, ping +from .client import BizHawkClient, AutoBizHawkClientRegister + + +EXPECTED_SCRIPT_VERSION = 1 + + +class BizHawkClientCommandProcessor(ClientCommandProcessor): + def _cmd_bh(self): + """Shows the current status of the client's connection to BizHawk""" + if isinstance(self.ctx, BizHawkClientContext): + if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED: + logger.info("BizHawk Connection Status: Not Connected") + elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE: + logger.info("BizHawk Connection Status: Tentatively Connected") + elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED: + logger.info("BizHawk Connection Status: Connected") + + +class BizHawkClientContext(CommonContext): + command_processor = BizHawkClientCommandProcessor + client_handler: Optional[BizHawkClient] + slot_data: Optional[Dict[str, Any]] = None + rom_hash: Optional[str] = None + bizhawk_ctx: BizHawkContext + + watcher_timeout: float + """The maximum amount of time the game watcher loop will wait for an update from the server before executing""" + + def __init__(self, server_address: Optional[str], password: Optional[str]): + super().__init__(server_address, password) + self.client_handler = None + self.bizhawk_ctx = BizHawkContext() + self.watcher_timeout = 0.5 + + def run_gui(self): + from kvui import GameManager + + class BizHawkManager(GameManager): + base_title = "Archipelago BizHawk Client" + + self.ui = BizHawkManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def on_package(self, cmd, args): + if cmd == "Connected": + self.slot_data = args.get("slot_data", None) + + if self.client_handler is not None: + self.client_handler.on_package(self, cmd, args) + + +async def _game_watcher(ctx: BizHawkClientContext): + showed_connecting_message = False + showed_connected_message = False + showed_no_handler_message = False + + while not ctx.exit_event.is_set(): + try: + await asyncio.wait_for(ctx.watcher_event.wait(), ctx.watcher_timeout) + except asyncio.TimeoutError: + pass + + ctx.watcher_event.clear() + + try: + if ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED: + showed_connected_message = False + + if not showed_connecting_message: + logger.info("Waiting to connect to BizHawk...") + showed_connecting_message = True + + if not await connect(ctx.bizhawk_ctx): + continue + + showed_no_handler_message = False + + script_version = await get_script_version(ctx.bizhawk_ctx) + + if script_version != EXPECTED_SCRIPT_VERSION: + logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but got {script_version}. Disconnecting.") + disconnect(ctx.bizhawk_ctx) + continue + + showed_connecting_message = False + + await ping(ctx.bizhawk_ctx) + + if not showed_connected_message: + showed_connected_message = True + logger.info("Connected to BizHawk") + + rom_hash = await get_hash(ctx.bizhawk_ctx) + if ctx.rom_hash is not None and ctx.rom_hash != rom_hash: + if ctx.server is not None: + logger.info(f"ROM changed. Disconnecting from server.") + await ctx.disconnect(True) + + ctx.auth = None + ctx.username = None + ctx.rom_hash = rom_hash + + if ctx.client_handler is None: + system = await get_system(ctx.bizhawk_ctx) + ctx.client_handler = await AutoBizHawkClientRegister.get_handler(ctx, system) + + if ctx.client_handler is None: + if not showed_no_handler_message: + logger.info("No handler was found for this game") + showed_no_handler_message = True + continue + else: + showed_no_handler_message = False + logger.info(f"Running handler for {ctx.client_handler.game}") + + except RequestFailedError as exc: + logger.info(f"Lost connection to BizHawk: {exc.args[0]}") + continue + + # Get slot name and send `Connect` + if ctx.server is not None and ctx.username is None: + await ctx.client_handler.set_auth(ctx) + + if ctx.auth is None: + await ctx.get_username() + + await ctx.send_connect() + + await ctx.client_handler.game_watcher(ctx) + + +async def _run_game(rom: str): + import os + auto_start = Utils.get_settings().bizhawkclient_options.rom_start + + if auto_start is True: + emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path + subprocess.Popen([emuhawk_path, "--lua=data/lua/connector_bizhawk_generic.lua", os.path.realpath(rom)], + cwd=Utils.local_path("."), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + elif isinstance(auto_start, str): + import shlex + + subprocess.Popen([*shlex.split(auto_start), os.path.realpath(rom)], + cwd=Utils.local_path("."), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +async def _patch_and_run_game(patch_file: str): + metadata, output_file = Patch.create_rom_file(patch_file) + Utils.async_start(_run_game(output_file)) + + +def launch() -> None: + async def main(): + parser = get_base_parser() + parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file") + args = parser.parse_args() + + ctx = BizHawkClientContext(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + if args.patch_file != "": + Utils.async_start(_patch_and_run_game(args.patch_file)) + + watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher") + + try: + await watcher_task + except Exception as e: + logger.error("".join(traceback.format_exception(e))) + + await ctx.exit_event.wait() + await ctx.shutdown() + + Utils.init_logging("BizHawkClient", exception_logger="Client") + import colorama + colorama.init() + asyncio.run(main()) + colorama.deinit() diff --git a/worlds/_sc2common/bot/maps.py b/worlds/_sc2common/bot/maps.py index f14b5af9009e..29ce9f658164 100644 --- a/worlds/_sc2common/bot/maps.py +++ b/worlds/_sc2common/bot/maps.py @@ -8,18 +8,31 @@ def get(name: str) -> Map: - # Iterate through 2 folder depths for map_dir in (p for p in Paths.MAPS.iterdir()): - if map_dir.is_dir(): - for map_file in (p for p in map_dir.iterdir()): - if Map.matches_target_map_name(map_file, name): - return Map(map_file) - elif Map.matches_target_map_name(map_dir, name): - return Map(map_dir) + map = find_map_in_dir(name, map_dir) + if map is not None: + return map raise KeyError(f"Map '{name}' was not found. Please put the map file in \"/StarCraft II/Maps/\".") +# Go deeper +def find_map_in_dir(name, path): + if Map.matches_target_map_name(path, name): + return Map(path) + + if path.name.endswith("SC2Map"): + return None + + if path.is_dir(): + for childPath in (p for p in path.iterdir()): + map = find_map_in_dir(name, childPath) + if map is not None: + return map + + return None + + class Map: def __init__(self, path: Path): diff --git a/worlds/alttp/Client.py b/worlds/alttp/Client.py index 7ac24fde9f28..22ef2a39a81a 100644 --- a/worlds/alttp/Client.py +++ b/worlds/alttp/Client.py @@ -107,7 +107,7 @@ "Hyrule Castle - Zelda's Chest": (0x80, 0x10), 'Hyrule Castle - Big Key Drop': (0x80, 0x400), 'Sewers - Dark Cross': (0x32, 0x10), - 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), + 'Sewers - Key Rat Key Drop': (0x21, 0x400), 'Sewers - Secret Room - Left': (0x11, 0x10), 'Sewers - Secret Room - Middle': (0x11, 0x20), 'Sewers - Secret Room - Right': (0x11, 0x40), diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index b789fd6db638..630d61e01959 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -8,7 +8,7 @@ from .Bosses import BossFactory, Boss from .Items import ItemFactory -from .Regions import lookup_boss_drops +from .Regions import lookup_boss_drops, key_drop_data from .Options import smallkey_shuffle if typing.TYPE_CHECKING: @@ -81,15 +81,17 @@ def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dunge return dungeon ES = make_dungeon('Hyrule Castle', None, ['Hyrule Castle', 'Sewers', 'Sewer Drop', 'Sewers (Dark)', 'Sanctuary'], - None, [ItemFactory('Small Key (Hyrule Castle)', player)], + ItemFactory('Big Key (Hyrule Castle)', player), + ItemFactory(['Small Key (Hyrule Castle)'] * 4, player), [ItemFactory('Map (Hyrule Castle)', player)]) EP = make_dungeon('Eastern Palace', 'Armos Knights', ['Eastern Palace'], - ItemFactory('Big Key (Eastern Palace)', player), [], + ItemFactory('Big Key (Eastern Palace)', player), + ItemFactory(['Small Key (Eastern Palace)'] * 2, player), ItemFactory(['Map (Eastern Palace)', 'Compass (Eastern Palace)'], player)) DP = make_dungeon('Desert Palace', 'Lanmolas', ['Desert Palace North', 'Desert Palace Main (Inner)', 'Desert Palace Main (Outer)', 'Desert Palace East'], ItemFactory('Big Key (Desert Palace)', player), - [ItemFactory('Small Key (Desert Palace)', player)], + ItemFactory(['Small Key (Desert Palace)'] * 4, player), ItemFactory(['Map (Desert Palace)', 'Compass (Desert Palace)'], player)) ToH = make_dungeon('Tower of Hera', 'Moldorm', ['Tower of Hera (Bottom)', 'Tower of Hera (Basement)', 'Tower of Hera (Top)'], @@ -105,7 +107,8 @@ def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dunge ItemFactory(['Small Key (Palace of Darkness)'] * 6, player), ItemFactory(['Map (Palace of Darkness)', 'Compass (Palace of Darkness)'], player)) TT = make_dungeon('Thieves Town', 'Blind', ['Thieves Town (Entrance)', 'Thieves Town (Deep)', 'Blind Fight'], - ItemFactory('Big Key (Thieves Town)', player), [ItemFactory('Small Key (Thieves Town)', player)], + ItemFactory('Big Key (Thieves Town)', player), + ItemFactory(['Small Key (Thieves Town)'] * 3, player), ItemFactory(['Map (Thieves Town)', 'Compass (Thieves Town)'], player)) SW = make_dungeon('Skull Woods', 'Mothula', ['Skull Woods Final Section (Entrance)', 'Skull Woods First Section', 'Skull Woods Second Section', 'Skull Woods Second Section (Drop)', @@ -113,52 +116,54 @@ def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dunge 'Skull Woods First Section (Right)', 'Skull Woods First Section (Left)', 'Skull Woods First Section (Top)'], ItemFactory('Big Key (Skull Woods)', player), - ItemFactory(['Small Key (Skull Woods)'] * 3, player), + ItemFactory(['Small Key (Skull Woods)'] * 5, player), ItemFactory(['Map (Skull Woods)', 'Compass (Skull Woods)'], player)) SP = make_dungeon('Swamp Palace', 'Arrghus', ['Swamp Palace (Entrance)', 'Swamp Palace (First Room)', 'Swamp Palace (Starting Area)', - 'Swamp Palace (Center)', 'Swamp Palace (North)'], ItemFactory('Big Key (Swamp Palace)', player), - [ItemFactory('Small Key (Swamp Palace)', player)], + 'Swamp Palace (West)', 'Swamp Palace (Center)', 'Swamp Palace (North)'], + ItemFactory('Big Key (Swamp Palace)', player), + ItemFactory(['Small Key (Swamp Palace)'] * 6, player), ItemFactory(['Map (Swamp Palace)', 'Compass (Swamp Palace)'], player)) IP = make_dungeon('Ice Palace', 'Kholdstare', - ['Ice Palace (Entrance)', 'Ice Palace (Main)', 'Ice Palace (East)', 'Ice Palace (East Top)', - 'Ice Palace (Kholdstare)'], ItemFactory('Big Key (Ice Palace)', player), - ItemFactory(['Small Key (Ice Palace)'] * 2, player), + ['Ice Palace (Entrance)', 'Ice Palace (Second Section)', 'Ice Palace (Main)', 'Ice Palace (East)', + 'Ice Palace (East Top)', 'Ice Palace (Kholdstare)'], ItemFactory('Big Key (Ice Palace)', player), + ItemFactory(['Small Key (Ice Palace)'] * 6, player), ItemFactory(['Map (Ice Palace)', 'Compass (Ice Palace)'], player)) MM = make_dungeon('Misery Mire', 'Vitreous', ['Misery Mire (Entrance)', 'Misery Mire (Main)', 'Misery Mire (West)', 'Misery Mire (Final Area)', 'Misery Mire (Vitreous)'], ItemFactory('Big Key (Misery Mire)', player), - ItemFactory(['Small Key (Misery Mire)'] * 3, player), + ItemFactory(['Small Key (Misery Mire)'] * 6, player), ItemFactory(['Map (Misery Mire)', 'Compass (Misery Mire)'], player)) TR = make_dungeon('Turtle Rock', 'Trinexx', ['Turtle Rock (Entrance)', 'Turtle Rock (First Section)', 'Turtle Rock (Chain Chomp Room)', + 'Turtle Rock (Pokey Room)', 'Turtle Rock (Second Section)', 'Turtle Rock (Big Chest)', 'Turtle Rock (Crystaroller Room)', 'Turtle Rock (Dark Room)', 'Turtle Rock (Eye Bridge)', 'Turtle Rock (Trinexx)'], ItemFactory('Big Key (Turtle Rock)', player), - ItemFactory(['Small Key (Turtle Rock)'] * 4, player), + ItemFactory(['Small Key (Turtle Rock)'] * 6, player), ItemFactory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], player)) if multiworld.mode[player] != 'inverted': AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None, - ItemFactory(['Small Key (Agahnims Tower)'] * 2, player), []) + ItemFactory(['Small Key (Agahnims Tower)'] * 4, player), []) GT = make_dungeon('Ganons Tower', 'Agahnim2', ['Ganons Tower (Entrance)', 'Ganons Tower (Tile Room)', 'Ganons Tower (Compass Room)', 'Ganons Tower (Hookshot Room)', 'Ganons Tower (Map Room)', 'Ganons Tower (Firesnake Room)', 'Ganons Tower (Teleport Room)', 'Ganons Tower (Bottom)', 'Ganons Tower (Top)', 'Ganons Tower (Before Moldorm)', 'Ganons Tower (Moldorm)', 'Agahnim 2'], ItemFactory('Big Key (Ganons Tower)', player), - ItemFactory(['Small Key (Ganons Tower)'] * 4, player), + ItemFactory(['Small Key (Ganons Tower)'] * 8, player), ItemFactory(['Map (Ganons Tower)', 'Compass (Ganons Tower)'], player)) else: AT = make_dungeon('Inverted Agahnims Tower', 'Agahnim', ['Inverted Agahnims Tower', 'Agahnim 1'], None, - ItemFactory(['Small Key (Agahnims Tower)'] * 2, player), []) + ItemFactory(['Small Key (Agahnims Tower)'] * 4, player), []) GT = make_dungeon('Inverted Ganons Tower', 'Agahnim2', ['Inverted Ganons Tower (Entrance)', 'Ganons Tower (Tile Room)', 'Ganons Tower (Compass Room)', 'Ganons Tower (Hookshot Room)', 'Ganons Tower (Map Room)', 'Ganons Tower (Firesnake Room)', 'Ganons Tower (Teleport Room)', 'Ganons Tower (Bottom)', 'Ganons Tower (Top)', 'Ganons Tower (Before Moldorm)', 'Ganons Tower (Moldorm)', 'Agahnim 2'], ItemFactory('Big Key (Ganons Tower)', player), - ItemFactory(['Small Key (Ganons Tower)'] * 4, player), + ItemFactory(['Small Key (Ganons Tower)'] * 8, player), ItemFactory(['Map (Ganons Tower)', 'Compass (Ganons Tower)'], player)) GT.bosses['bottom'] = BossFactory('Armos Knights', player) @@ -195,10 +200,11 @@ def fill_dungeons_restrictive(multiworld: MultiWorld): dungeon_specific: set = set() for subworld in multiworld.get_game_worlds("A Link to the Past"): player = subworld.player - localized |= {(player, item_name) for item_name in - subworld.dungeon_local_item_names} - dungeon_specific |= {(player, item_name) for item_name in - subworld.dungeon_specific_item_names} + if player not in multiworld.groups: + localized |= {(player, item_name) for item_name in + subworld.dungeon_local_item_names} + dungeon_specific |= {(player, item_name) for item_name in + subworld.dungeon_specific_item_names} if localized: in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized] @@ -249,7 +255,16 @@ def fill_dungeons_restrictive(multiworld: MultiWorld): if all_state_base.has("Triforce", player): all_state_base.remove(multiworld.worlds[player].create_item("Triforce")) - fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True, allow_excluded=True) + for (player, key_drop_shuffle) in multiworld.key_drop_shuffle.items(): + if not key_drop_shuffle and player not in multiworld.groups: + for key_loc in key_drop_data: + key_data = key_drop_data[key_loc] + all_state_base.remove(ItemFactory(key_data[3], player)) + loc = multiworld.get_location(key_loc, player) + + if loc in all_state_base.events: + all_state_base.events.remove(loc) + fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True) dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A], diff --git a/worlds/alttp/EntranceShuffle.py b/worlds/alttp/EntranceShuffle.py index b7fe688431b7..07bb587eebe3 100644 --- a/worlds/alttp/EntranceShuffle.py +++ b/worlds/alttp/EntranceShuffle.py @@ -3134,6 +3134,7 @@ def plando_connect(world, player: int): ('Swamp Palace Moat', 'Swamp Palace (First Room)'), ('Swamp Palace Small Key Door', 'Swamp Palace (Starting Area)'), ('Swamp Palace (Center)', 'Swamp Palace (Center)'), + ('Swamp Palace (West)', 'Swamp Palace (West)'), ('Swamp Palace (North)', 'Swamp Palace (North)'), ('Thieves Town Big Key Door', 'Thieves Town (Deep)'), ('Skull Woods Torch Room', 'Skull Woods Final Section (Mothula)'), @@ -3148,7 +3149,8 @@ def plando_connect(world, player: int): ('Blind Fight', 'Blind Fight'), ('Desert Palace Pots (Outer)', 'Desert Palace Main (Inner)'), ('Desert Palace Pots (Inner)', 'Desert Palace Main (Outer)'), - ('Ice Palace Entrance Room', 'Ice Palace (Main)'), + ('Ice Palace (Main)', 'Ice Palace (Main)'), + ('Ice Palace (Second Section)', 'Ice Palace (Second Section)'), ('Ice Palace (East)', 'Ice Palace (East)'), ('Ice Palace (East Top)', 'Ice Palace (East Top)'), ('Ice Palace (Kholdstare)', 'Ice Palace (Kholdstare)'), @@ -3158,9 +3160,11 @@ def plando_connect(world, player: int): ('Misery Mire (Vitreous)', 'Misery Mire (Vitreous)'), ('Turtle Rock Entrance Gap', 'Turtle Rock (First Section)'), ('Turtle Rock Entrance Gap Reverse', 'Turtle Rock (Entrance)'), - ('Turtle Rock Pokey Room', 'Turtle Rock (Chain Chomp Room)'), + ('Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room)'), + ('Turtle Rock (Pokey Room) (South)', 'Turtle Rock (First Section)'), + ('Turtle Rock (Pokey Room) (North)', 'Turtle Rock (Chain Chomp Room)'), ('Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Second Section)'), - ('Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock (First Section)'), + ('Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock (Pokey Room)'), ('Turtle Rock Chain Chomp Staircase', 'Turtle Rock (Chain Chomp Room)'), ('Turtle Rock (Big Chest) (North)', 'Turtle Rock (Second Section)'), ('Turtle Rock Big Key Door', 'Turtle Rock (Crystaroller Room)'), @@ -3285,6 +3289,7 @@ def plando_connect(world, player: int): ('Swamp Palace Moat', 'Swamp Palace (First Room)'), ('Swamp Palace Small Key Door', 'Swamp Palace (Starting Area)'), ('Swamp Palace (Center)', 'Swamp Palace (Center)'), + ('Swamp Palace (West)', 'Swamp Palace (West)'), ('Swamp Palace (North)', 'Swamp Palace (North)'), ('Thieves Town Big Key Door', 'Thieves Town (Deep)'), ('Skull Woods Torch Room', 'Skull Woods Final Section (Mothula)'), @@ -3299,7 +3304,8 @@ def plando_connect(world, player: int): ('Blind Fight', 'Blind Fight'), ('Desert Palace Pots (Outer)', 'Desert Palace Main (Inner)'), ('Desert Palace Pots (Inner)', 'Desert Palace Main (Outer)'), - ('Ice Palace Entrance Room', 'Ice Palace (Main)'), + ('Ice Palace (Main)', 'Ice Palace (Main)'), + ('Ice Palace (Second Section)', 'Ice Palace (Second Section)'), ('Ice Palace (East)', 'Ice Palace (East)'), ('Ice Palace (East Top)', 'Ice Palace (East Top)'), ('Ice Palace (Kholdstare)', 'Ice Palace (Kholdstare)'), @@ -3309,9 +3315,11 @@ def plando_connect(world, player: int): ('Misery Mire (Vitreous)', 'Misery Mire (Vitreous)'), ('Turtle Rock Entrance Gap', 'Turtle Rock (First Section)'), ('Turtle Rock Entrance Gap Reverse', 'Turtle Rock (Entrance)'), - ('Turtle Rock Pokey Room', 'Turtle Rock (Chain Chomp Room)'), + ('Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room)'), + ('Turtle Rock (Pokey Room) (South)', 'Turtle Rock (First Section)'), + ('Turtle Rock (Pokey Room) (North)', 'Turtle Rock (Chain Chomp Room)'), ('Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Second Section)'), - ('Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock (First Section)'), + ('Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock (Pokey Room)'), ('Turtle Rock Chain Chomp Staircase', 'Turtle Rock (Chain Chomp Room)'), ('Turtle Rock (Big Chest) (North)', 'Turtle Rock (Second Section)'), ('Turtle Rock Big Key Door', 'Turtle Rock (Crystaroller Room)'), diff --git a/worlds/alttp/InvertedRegions.py b/worlds/alttp/InvertedRegions.py index acec73bf33be..ffa23881d3d9 100644 --- a/worlds/alttp/InvertedRegions.py +++ b/worlds/alttp/InvertedRegions.py @@ -149,41 +149,37 @@ def create_inverted_regions(world, player): create_lw_region(world, player, 'Desert Palace Entrance (North) Spot', None, ['Desert Palace Entrance (North)', 'Desert Ledge Return Rocks', 'Desert Palace North Mirror Spot']), - create_dungeon_region(world, player, 'Desert Palace Main (Outer)', 'Desert Palace', - ['Desert Palace - Big Chest', 'Desert Palace - Torch', 'Desert Palace - Map Chest'], - ['Desert Palace Pots (Outer)', 'Desert Palace Exit (West)', 'Desert Palace Exit (East)', - 'Desert Palace East Wing']), - create_dungeon_region(world, player, 'Desert Palace Main (Inner)', 'Desert Palace', None, - ['Desert Palace Exit (South)', 'Desert Palace Pots (Inner)']), - create_dungeon_region(world, player, 'Desert Palace East', 'Desert Palace', - ['Desert Palace - Compass Chest', 'Desert Palace - Big Key Chest']), + create_dungeon_region(world, player, 'Desert Palace Main (Outer)', 'Desert Palace', ['Desert Palace - Big Chest', 'Desert Palace - Torch', 'Desert Palace - Map Chest'], + ['Desert Palace Pots (Outer)', 'Desert Palace Exit (West)', 'Desert Palace Exit (East)', 'Desert Palace East Wing']), + create_dungeon_region(world, player, 'Desert Palace Main (Inner)', 'Desert Palace', None, ['Desert Palace Exit (South)', 'Desert Palace Pots (Inner)']), + create_dungeon_region(world, player, 'Desert Palace East', 'Desert Palace', ['Desert Palace - Compass Chest', 'Desert Palace - Big Key Chest']), create_dungeon_region(world, player, 'Desert Palace North', 'Desert Palace', - ['Desert Palace - Boss', 'Desert Palace - Prize'], ['Desert Palace Exit (North)']), + ['Desert Palace - Desert Tiles 1 Pot Key', 'Desert Palace - Beamos Hall Pot Key', + 'Desert Palace - Desert Tiles 2 Pot Key', + 'Desert Palace - Boss', 'Desert Palace - Prize'], ['Desert Palace Exit (North)']), create_dungeon_region(world, player, 'Eastern Palace', 'Eastern Palace', ['Eastern Palace - Compass Chest', 'Eastern Palace - Big Chest', 'Eastern Palace - Cannonball Chest', - 'Eastern Palace - Big Key Chest', 'Eastern Palace - Map Chest', 'Eastern Palace - Boss', - 'Eastern Palace - Prize'], ['Eastern Palace Exit']), + 'Eastern Palace - Dark Square Pot Key', 'Eastern Palace - Dark Eyegore Key Drop', + 'Eastern Palace - Big Key Chest', + 'Eastern Palace - Map Chest', 'Eastern Palace - Boss', 'Eastern Palace - Prize'], + ['Eastern Palace Exit']), create_lw_region(world, player, 'Master Sword Meadow', ['Master Sword Pedestal']), create_cave_region(world, player, 'Lost Woods Gamble', 'a game of chance'), - create_lw_region(world, player, 'Hyrule Castle Ledge', None, - ['Hyrule Castle Entrance (East)', 'Hyrule Castle Entrance (West)', 'Inverted Ganons Tower', - 'Hyrule Castle Ledge Courtyard Drop', 'Inverted Pyramid Hole']), + create_lw_region(world, player, 'Hyrule Castle Ledge', None, ['Hyrule Castle Entrance (East)', 'Hyrule Castle Entrance (West)', 'Inverted Ganons Tower', 'Hyrule Castle Ledge Courtyard Drop', 'Inverted Pyramid Hole']), create_dungeon_region(world, player, 'Hyrule Castle', 'Hyrule Castle', ['Hyrule Castle - Boomerang Chest', 'Hyrule Castle - Map Chest', - 'Hyrule Castle - Zelda\'s Chest'], + 'Hyrule Castle - Zelda\'s Chest', + 'Hyrule Castle - Map Guard Key Drop', 'Hyrule Castle - Boomerang Guard Key Drop', + 'Hyrule Castle - Big Key Drop'], ['Hyrule Castle Exit (East)', 'Hyrule Castle Exit (West)', 'Hyrule Castle Exit (South)', 'Throne Room']), create_dungeon_region(world, player, 'Sewer Drop', 'a drop\'s exit', None, ['Sewer Drop']), # This exists only to be referenced for access checks - create_dungeon_region(world, player, 'Sewers (Dark)', 'a drop\'s exit', ['Sewers - Dark Cross'], - ['Sewers Door']), - create_dungeon_region(world, player, 'Sewers', 'a drop\'s exit', - ['Sewers - Secret Room - Left', 'Sewers - Secret Room - Middle', - 'Sewers - Secret Room - Right'], ['Sanctuary Push Door', 'Sewers Back Door']), + create_dungeon_region(world, player, 'Sewers (Dark)', 'a drop\'s exit', ['Sewers - Dark Cross', 'Sewers - Key Rat Key Drop'], ['Sewers Door']), + create_dungeon_region(world, player, 'Sewers', 'a drop\'s exit', ['Sewers - Secret Room - Left', 'Sewers - Secret Room - Middle', + 'Sewers - Secret Room - Right'], ['Sanctuary Push Door', 'Sewers Back Door']), create_dungeon_region(world, player, 'Sanctuary', 'a drop\'s exit', ['Sanctuary'], ['Sanctuary Exit']), - create_dungeon_region(world, player, 'Inverted Agahnims Tower', 'Castle Tower', - ['Castle Tower - Room 03', 'Castle Tower - Dark Maze'], - ['Agahnim 1', 'Inverted Agahnims Tower Exit']), + create_dungeon_region(world, player, 'Inverted Agahnims Tower', 'Castle Tower', ['Castle Tower - Room 03', 'Castle Tower - Dark Maze', 'Castle Tower - Dark Archer Key Drop', 'Castle Tower - Circle of Pots Key Drop'], ['Agahnim 1', 'Inverted Agahnims Tower Exit']), create_dungeon_region(world, player, 'Agahnim 1', 'Castle Tower', ['Agahnim 1'], None), create_cave_region(world, player, 'Old Man Cave', 'a connector', ['Old Man'], ['Old Man Cave Exit (East)', 'Old Man Cave Exit (West)']), @@ -253,14 +249,9 @@ def create_inverted_regions(world, player): 'Death Mountain (Top) Mirror Spot']), create_dw_region(world, player, 'Bumper Cave Ledge', ['Bumper Cave Ledge'], ['Bumper Cave Ledge Drop', 'Bumper Cave (Top)']), - create_dungeon_region(world, player, 'Tower of Hera (Bottom)', 'Tower of Hera', - ['Tower of Hera - Basement Cage', 'Tower of Hera - Map Chest'], - ['Tower of Hera Small Key Door', 'Tower of Hera Big Key Door', 'Tower of Hera Exit']), - create_dungeon_region(world, player, 'Tower of Hera (Basement)', 'Tower of Hera', - ['Tower of Hera - Big Key Chest']), - create_dungeon_region(world, player, 'Tower of Hera (Top)', 'Tower of Hera', - ['Tower of Hera - Compass Chest', 'Tower of Hera - Big Chest', 'Tower of Hera - Boss', - 'Tower of Hera - Prize']), + create_dungeon_region(world, player, 'Tower of Hera (Bottom)', 'Tower of Hera', ['Tower of Hera - Basement Cage', 'Tower of Hera - Map Chest'], ['Tower of Hera Small Key Door', 'Tower of Hera Big Key Door', 'Tower of Hera Exit']), + create_dungeon_region(world, player, 'Tower of Hera (Basement)', 'Tower of Hera', ['Tower of Hera - Big Key Chest']), + create_dungeon_region(world, player, 'Tower of Hera (Top)', 'Tower of Hera', ['Tower of Hera - Compass Chest', 'Tower of Hera - Big Chest', 'Tower of Hera - Boss', 'Tower of Hera - Prize']), create_dw_region(world, player, 'East Dark World', ['Pyramid'], ['Pyramid Fairy', 'South Dark World Bridge', 'Palace of Darkness', @@ -360,128 +351,82 @@ def create_inverted_regions(world, player): ['Floating Island Drop', 'Hookshot Cave Back Entrance']), create_cave_region(world, player, 'Mimic Cave', 'Mimic Cave', ['Mimic Cave']), - create_dungeon_region(world, player, 'Swamp Palace (Entrance)', 'Swamp Palace', None, - ['Swamp Palace Moat', 'Swamp Palace Exit']), - create_dungeon_region(world, player, 'Swamp Palace (First Room)', 'Swamp Palace', ['Swamp Palace - Entrance'], - ['Swamp Palace Small Key Door']), - create_dungeon_region(world, player, 'Swamp Palace (Starting Area)', 'Swamp Palace', - ['Swamp Palace - Map Chest'], ['Swamp Palace (Center)']), - create_dungeon_region(world, player, 'Swamp Palace (Center)', 'Swamp Palace', - ['Swamp Palace - Big Chest', 'Swamp Palace - Compass Chest', - 'Swamp Palace - Big Key Chest', 'Swamp Palace - West Chest'], ['Swamp Palace (North)']), - create_dungeon_region(world, player, 'Swamp Palace (North)', 'Swamp Palace', - ['Swamp Palace - Flooded Room - Left', 'Swamp Palace - Flooded Room - Right', - 'Swamp Palace - Waterfall Room', 'Swamp Palace - Boss', 'Swamp Palace - Prize']), - create_dungeon_region(world, player, 'Thieves Town (Entrance)', 'Thieves\' Town', - ['Thieves\' Town - Big Key Chest', - 'Thieves\' Town - Map Chest', - 'Thieves\' Town - Compass Chest', - 'Thieves\' Town - Ambush Chest'], ['Thieves Town Big Key Door', 'Thieves Town Exit']), + create_dungeon_region(world, player, 'Swamp Palace (Entrance)', 'Swamp Palace', None, ['Swamp Palace Moat', 'Swamp Palace Exit']), + create_dungeon_region(world, player, 'Swamp Palace (First Room)', 'Swamp Palace', ['Swamp Palace - Entrance'], ['Swamp Palace Small Key Door']), + create_dungeon_region(world, player, 'Swamp Palace (Starting Area)', 'Swamp Palace', ['Swamp Palace - Map Chest', 'Swamp Palace - Pot Row Pot Key', + 'Swamp Palace - Trench 1 Pot Key'], ['Swamp Palace (Center)']), + create_dungeon_region(world, player, 'Swamp Palace (Center)', 'Swamp Palace', ['Swamp Palace - Big Chest', 'Swamp Palace - Compass Chest', 'Swamp Palace - Hookshot Pot Key', + 'Swamp Palace - Trench 2 Pot Key'], ['Swamp Palace (North)', 'Swamp Palace (West)']), + create_dungeon_region(world, player, 'Swamp Palace (West)', 'Swamp Palace', ['Swamp Palace - Big Key Chest', 'Swamp Palace - West Chest']), + create_dungeon_region(world, player, 'Swamp Palace (North)', 'Swamp Palace', ['Swamp Palace - Flooded Room - Left', 'Swamp Palace - Flooded Room - Right', + 'Swamp Palace - Waterway Pot Key', 'Swamp Palace - Waterfall Room', + 'Swamp Palace - Boss', 'Swamp Palace - Prize']), + create_dungeon_region(world, player, 'Thieves Town (Entrance)', 'Thieves\' Town', ['Thieves\' Town - Big Key Chest', + 'Thieves\' Town - Map Chest', + 'Thieves\' Town - Compass Chest', + 'Thieves\' Town - Ambush Chest'], ['Thieves Town Big Key Door', 'Thieves Town Exit']), create_dungeon_region(world, player, 'Thieves Town (Deep)', 'Thieves\' Town', ['Thieves\' Town - Attic', - 'Thieves\' Town - Big Chest', - 'Thieves\' Town - Blind\'s Cell'], + 'Thieves\' Town - Big Chest', + 'Thieves\' Town - Hallway Pot Key', + 'Thieves\' Town - Spike Switch Pot Key', + 'Thieves\' Town - Blind\'s Cell'], ['Blind Fight']), - create_dungeon_region(world, player, 'Blind Fight', 'Thieves\' Town', - ['Thieves\' Town - Boss', 'Thieves\' Town - Prize']), - create_dungeon_region(world, player, 'Skull Woods First Section', 'Skull Woods', ['Skull Woods - Map Chest'], - ['Skull Woods First Section Exit', 'Skull Woods First Section Bomb Jump', - 'Skull Woods First Section South Door', 'Skull Woods First Section West Door']), - create_dungeon_region(world, player, 'Skull Woods First Section (Right)', 'Skull Woods', - ['Skull Woods - Pinball Room'], ['Skull Woods First Section (Right) North Door']), - create_dungeon_region(world, player, 'Skull Woods First Section (Left)', 'Skull Woods', - ['Skull Woods - Compass Chest', 'Skull Woods - Pot Prison'], - ['Skull Woods First Section (Left) Door to Exit', - 'Skull Woods First Section (Left) Door to Right']), - create_dungeon_region(world, player, 'Skull Woods First Section (Top)', 'Skull Woods', - ['Skull Woods - Big Chest'], ['Skull Woods First Section (Top) One-Way Path']), - create_dungeon_region(world, player, 'Skull Woods Second Section (Drop)', 'Skull Woods', None, - ['Skull Woods Second Section (Drop)']), - create_dungeon_region(world, player, 'Skull Woods Second Section', 'Skull Woods', - ['Skull Woods - Big Key Chest'], - ['Skull Woods Second Section Exit (East)', 'Skull Woods Second Section Exit (West)']), - create_dungeon_region(world, player, 'Skull Woods Final Section (Entrance)', 'Skull Woods', - ['Skull Woods - Bridge Room'], - ['Skull Woods Torch Room', 'Skull Woods Final Section Exit']), - create_dungeon_region(world, player, 'Skull Woods Final Section (Mothula)', 'Skull Woods', - ['Skull Woods - Boss', 'Skull Woods - Prize']), - create_dungeon_region(world, player, 'Ice Palace (Entrance)', 'Ice Palace', None, - ['Ice Palace Entrance Room', 'Ice Palace Exit']), - create_dungeon_region(world, player, 'Ice Palace (Main)', 'Ice Palace', - ['Ice Palace - Compass Chest', 'Ice Palace - Freezor Chest', - 'Ice Palace - Big Chest', 'Ice Palace - Iced T Room'], - ['Ice Palace (East)', 'Ice Palace (Kholdstare)']), - create_dungeon_region(world, player, 'Ice Palace (East)', 'Ice Palace', ['Ice Palace - Spike Room'], - ['Ice Palace (East Top)']), - create_dungeon_region(world, player, 'Ice Palace (East Top)', 'Ice Palace', - ['Ice Palace - Big Key Chest', 'Ice Palace - Map Chest']), - create_dungeon_region(world, player, 'Ice Palace (Kholdstare)', 'Ice Palace', - ['Ice Palace - Boss', 'Ice Palace - Prize']), - create_dungeon_region(world, player, 'Misery Mire (Entrance)', 'Misery Mire', None, - ['Misery Mire Entrance Gap', 'Misery Mire Exit']), - create_dungeon_region(world, player, 'Misery Mire (Main)', 'Misery Mire', - ['Misery Mire - Big Chest', 'Misery Mire - Map Chest', 'Misery Mire - Main Lobby', - 'Misery Mire - Bridge Chest', 'Misery Mire - Spike Chest'], - ['Misery Mire (West)', 'Misery Mire Big Key Door']), - create_dungeon_region(world, player, 'Misery Mire (West)', 'Misery Mire', - ['Misery Mire - Compass Chest', 'Misery Mire - Big Key Chest']), - create_dungeon_region(world, player, 'Misery Mire (Final Area)', 'Misery Mire', None, - ['Misery Mire (Vitreous)']), - create_dungeon_region(world, player, 'Misery Mire (Vitreous)', 'Misery Mire', - ['Misery Mire - Boss', 'Misery Mire - Prize']), - create_dungeon_region(world, player, 'Turtle Rock (Entrance)', 'Turtle Rock', None, - ['Turtle Rock Entrance Gap', 'Turtle Rock Exit (Front)']), - create_dungeon_region(world, player, 'Turtle Rock (First Section)', 'Turtle Rock', - ['Turtle Rock - Compass Chest', 'Turtle Rock - Roller Room - Left', - 'Turtle Rock - Roller Room - Right'], - ['Turtle Rock Pokey Room', 'Turtle Rock Entrance Gap Reverse']), - create_dungeon_region(world, player, 'Turtle Rock (Chain Chomp Room)', 'Turtle Rock', - ['Turtle Rock - Chain Chomps'], + create_dungeon_region(world, player, 'Blind Fight', 'Thieves\' Town', ['Thieves\' Town - Boss', 'Thieves\' Town - Prize']), + create_dungeon_region(world, player, 'Skull Woods First Section', 'Skull Woods', ['Skull Woods - Map Chest'], ['Skull Woods First Section Exit', 'Skull Woods First Section Bomb Jump', 'Skull Woods First Section South Door', 'Skull Woods First Section West Door']), + create_dungeon_region(world, player, 'Skull Woods First Section (Right)', 'Skull Woods', ['Skull Woods - Pinball Room'], ['Skull Woods First Section (Right) North Door']), + create_dungeon_region(world, player, 'Skull Woods First Section (Left)', 'Skull Woods', ['Skull Woods - Compass Chest', 'Skull Woods - Pot Prison'], ['Skull Woods First Section (Left) Door to Exit', 'Skull Woods First Section (Left) Door to Right']), + create_dungeon_region(world, player, 'Skull Woods First Section (Top)', 'Skull Woods', ['Skull Woods - Big Chest'], ['Skull Woods First Section (Top) One-Way Path']), + create_dungeon_region(world, player, 'Skull Woods Second Section (Drop)', 'Skull Woods', None, ['Skull Woods Second Section (Drop)']), + create_dungeon_region(world, player, 'Skull Woods Second Section', 'Skull Woods', ['Skull Woods - Big Key Chest', 'Skull Woods - West Lobby Pot Key'], ['Skull Woods Second Section Exit (East)', 'Skull Woods Second Section Exit (West)']), + create_dungeon_region(world, player, 'Skull Woods Final Section (Entrance)', 'Skull Woods', ['Skull Woods - Bridge Room', 'Skull Woods - Spike Corner Key Drop'], ['Skull Woods Torch Room', 'Skull Woods Final Section Exit']), + create_dungeon_region(world, player, 'Skull Woods Final Section (Mothula)', 'Skull Woods', ['Skull Woods - Boss', 'Skull Woods - Prize']), + create_dungeon_region(world, player, 'Ice Palace (Entrance)', 'Ice Palace', ['Ice Palace - Jelly Key Drop'], ['Ice Palace (Second Section)', 'Ice Palace Exit']), + create_dungeon_region(world, player, 'Ice Palace (Second Section)', 'Ice Palace', ['Ice Palace - Conveyor Key Drop', 'Ice Palace - Compass Chest'], ['Ice Palace (Main)']), + create_dungeon_region(world, player, 'Ice Palace (Main)', 'Ice Palace', ['Ice Palace - Freezor Chest', + 'Ice Palace - Many Pots Pot Key', + 'Ice Palace - Big Chest', 'Ice Palace - Iced T Room'], ['Ice Palace (East)', 'Ice Palace (Kholdstare)']), + create_dungeon_region(world, player, 'Ice Palace (East)', 'Ice Palace', ['Ice Palace - Spike Room'], ['Ice Palace (East Top)']), + create_dungeon_region(world, player, 'Ice Palace (East Top)', 'Ice Palace', ['Ice Palace - Big Key Chest', 'Ice Palace - Map Chest', 'Ice Palace - Hammer Block Key Drop']), + create_dungeon_region(world, player, 'Ice Palace (Kholdstare)', 'Ice Palace', ['Ice Palace - Boss', 'Ice Palace - Prize']), + create_dungeon_region(world, player, 'Misery Mire (Entrance)', 'Misery Mire', None, ['Misery Mire Entrance Gap', 'Misery Mire Exit']), + create_dungeon_region(world, player, 'Misery Mire (Main)', 'Misery Mire', ['Misery Mire - Big Chest', 'Misery Mire - Map Chest', 'Misery Mire - Main Lobby', + 'Misery Mire - Bridge Chest', 'Misery Mire - Spike Chest', + 'Misery Mire - Spikes Pot Key', 'Misery Mire - Fishbone Pot Key', + 'Misery Mire - Conveyor Crystal Key Drop'], ['Misery Mire (West)', 'Misery Mire Big Key Door']), + create_dungeon_region(world, player, 'Misery Mire (West)', 'Misery Mire', ['Misery Mire - Compass Chest', 'Misery Mire - Big Key Chest']), + create_dungeon_region(world, player, 'Misery Mire (Final Area)', 'Misery Mire', None, ['Misery Mire (Vitreous)']), + create_dungeon_region(world, player, 'Misery Mire (Vitreous)', 'Misery Mire', ['Misery Mire - Boss', 'Misery Mire - Prize']), + create_dungeon_region(world, player, 'Turtle Rock (Entrance)', 'Turtle Rock', None, ['Turtle Rock Entrance Gap', 'Turtle Rock Exit (Front)']), + create_dungeon_region(world, player, 'Turtle Rock (First Section)', 'Turtle Rock', ['Turtle Rock - Compass Chest', 'Turtle Rock - Roller Room - Left', + 'Turtle Rock - Roller Room - Right'], + ['Turtle Rock Entrance to Pokey Room', 'Turtle Rock Entrance Gap Reverse']), + create_dungeon_region(world, player, 'Turtle Rock (Pokey Room)', 'Turtle Rock', ['Turtle Rock - Pokey 1 Key Drop'], ['Turtle Rock (Pokey Room) (North)', 'Turtle Rock (Pokey Room) (South)']), + create_dungeon_region(world, player, 'Turtle Rock (Chain Chomp Room)', 'Turtle Rock', ['Turtle Rock - Chain Chomps'], ['Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)']), create_dungeon_region(world, player, 'Turtle Rock (Second Section)', 'Turtle Rock', - ['Turtle Rock - Big Key Chest'], + ['Turtle Rock - Big Key Chest', 'Turtle Rock - Pokey 2 Key Drop'], ['Turtle Rock Ledge Exit (West)', 'Turtle Rock Chain Chomp Staircase', 'Turtle Rock Big Key Door']), - create_dungeon_region(world, player, 'Turtle Rock (Big Chest)', 'Turtle Rock', ['Turtle Rock - Big Chest'], - ['Turtle Rock (Big Chest) (North)', 'Turtle Rock Ledge Exit (East)']), - create_dungeon_region(world, player, 'Turtle Rock (Crystaroller Room)', 'Turtle Rock', - ['Turtle Rock - Crystaroller Room'], - ['Turtle Rock Dark Room Staircase', 'Turtle Rock Big Key Door Reverse']), - create_dungeon_region(world, player, 'Turtle Rock (Dark Room)', 'Turtle Rock', None, - ['Turtle Rock (Dark Room) (North)', 'Turtle Rock (Dark Room) (South)']), - create_dungeon_region(world, player, 'Turtle Rock (Eye Bridge)', 'Turtle Rock', - ['Turtle Rock - Eye Bridge - Bottom Left', 'Turtle Rock - Eye Bridge - Bottom Right', - 'Turtle Rock - Eye Bridge - Top Left', 'Turtle Rock - Eye Bridge - Top Right'], - ['Turtle Rock Dark Room (South)', 'Turtle Rock (Trinexx)', - 'Turtle Rock Isolated Ledge Exit']), - create_dungeon_region(world, player, 'Turtle Rock (Trinexx)', 'Turtle Rock', - ['Turtle Rock - Boss', 'Turtle Rock - Prize']), - create_dungeon_region(world, player, 'Palace of Darkness (Entrance)', 'Palace of Darkness', - ['Palace of Darkness - Shooter Room'], - ['Palace of Darkness Bridge Room', 'Palace of Darkness Bonk Wall', - 'Palace of Darkness Exit']), - create_dungeon_region(world, player, 'Palace of Darkness (Center)', 'Palace of Darkness', - ['Palace of Darkness - The Arena - Bridge', 'Palace of Darkness - Stalfos Basement'], - ['Palace of Darkness Big Key Chest Staircase', 'Palace of Darkness (North)', - 'Palace of Darkness Big Key Door']), - create_dungeon_region(world, player, 'Palace of Darkness (Big Key Chest)', 'Palace of Darkness', - ['Palace of Darkness - Big Key Chest']), - create_dungeon_region(world, player, 'Palace of Darkness (Bonk Section)', 'Palace of Darkness', - ['Palace of Darkness - The Arena - Ledge', 'Palace of Darkness - Map Chest'], - ['Palace of Darkness Hammer Peg Drop']), - create_dungeon_region(world, player, 'Palace of Darkness (North)', 'Palace of Darkness', - ['Palace of Darkness - Compass Chest', 'Palace of Darkness - Dark Basement - Left', - 'Palace of Darkness - Dark Basement - Right'], + create_dungeon_region(world, player, 'Turtle Rock (Big Chest)', 'Turtle Rock', ['Turtle Rock - Big Chest'], ['Turtle Rock (Big Chest) (North)', 'Turtle Rock Ledge Exit (East)']), + create_dungeon_region(world, player, 'Turtle Rock (Crystaroller Room)', 'Turtle Rock', ['Turtle Rock - Crystaroller Room'], ['Turtle Rock Dark Room Staircase', 'Turtle Rock Big Key Door Reverse']), + create_dungeon_region(world, player, 'Turtle Rock (Dark Room)', 'Turtle Rock', None, ['Turtle Rock (Dark Room) (North)', 'Turtle Rock (Dark Room) (South)']), + create_dungeon_region(world, player, 'Turtle Rock (Eye Bridge)', 'Turtle Rock', ['Turtle Rock - Eye Bridge - Bottom Left', 'Turtle Rock - Eye Bridge - Bottom Right', + 'Turtle Rock - Eye Bridge - Top Left', 'Turtle Rock - Eye Bridge - Top Right'], + ['Turtle Rock Dark Room (South)', 'Turtle Rock (Trinexx)', 'Turtle Rock Isolated Ledge Exit']), + create_dungeon_region(world, player, 'Turtle Rock (Trinexx)', 'Turtle Rock', ['Turtle Rock - Boss', 'Turtle Rock - Prize']), + create_dungeon_region(world, player, 'Palace of Darkness (Entrance)', 'Palace of Darkness', ['Palace of Darkness - Shooter Room'], ['Palace of Darkness Bridge Room', 'Palace of Darkness Bonk Wall', 'Palace of Darkness Exit']), + create_dungeon_region(world, player, 'Palace of Darkness (Center)', 'Palace of Darkness', ['Palace of Darkness - The Arena - Bridge', 'Palace of Darkness - Stalfos Basement'], + ['Palace of Darkness Big Key Chest Staircase', 'Palace of Darkness (North)', 'Palace of Darkness Big Key Door']), + create_dungeon_region(world, player, 'Palace of Darkness (Big Key Chest)', 'Palace of Darkness', ['Palace of Darkness - Big Key Chest']), + create_dungeon_region(world, player, 'Palace of Darkness (Bonk Section)', 'Palace of Darkness', ['Palace of Darkness - The Arena - Ledge', 'Palace of Darkness - Map Chest'], ['Palace of Darkness Hammer Peg Drop']), + create_dungeon_region(world, player, 'Palace of Darkness (North)', 'Palace of Darkness', ['Palace of Darkness - Compass Chest', 'Palace of Darkness - Dark Basement - Left', 'Palace of Darkness - Dark Basement - Right'], ['Palace of Darkness Spike Statue Room Door', 'Palace of Darkness Maze Door']), - create_dungeon_region(world, player, 'Palace of Darkness (Maze)', 'Palace of Darkness', - ['Palace of Darkness - Dark Maze - Top', 'Palace of Darkness - Dark Maze - Bottom', - 'Palace of Darkness - Big Chest']), - create_dungeon_region(world, player, 'Palace of Darkness (Harmless Hellway)', 'Palace of Darkness', - ['Palace of Darkness - Harmless Hellway']), - create_dungeon_region(world, player, 'Palace of Darkness (Final Section)', 'Palace of Darkness', - ['Palace of Darkness - Boss', 'Palace of Darkness - Prize']), + create_dungeon_region(world, player, 'Palace of Darkness (Maze)', 'Palace of Darkness', ['Palace of Darkness - Dark Maze - Top', 'Palace of Darkness - Dark Maze - Bottom', 'Palace of Darkness - Big Chest']), + create_dungeon_region(world, player, 'Palace of Darkness (Harmless Hellway)', 'Palace of Darkness', ['Palace of Darkness - Harmless Hellway']), + create_dungeon_region(world, player, 'Palace of Darkness (Final Section)', 'Palace of Darkness', ['Palace of Darkness - Boss', 'Palace of Darkness - Prize']), create_dungeon_region(world, player, 'Inverted Ganons Tower (Entrance)', 'Ganon\'s Tower', ['Ganons Tower - Bob\'s Torch', 'Ganons Tower - Hope Room - Left', - 'Ganons Tower - Hope Room - Right'], + 'Ganons Tower - Hope Room - Right', 'Ganons Tower - Conveyor Cross Pot Key'], ['Ganons Tower (Tile Room)', 'Ganons Tower (Hookshot Room)', 'Ganons Tower Big Key Door', 'Inverted Ganons Tower Exit']), create_dungeon_region(world, player, 'Ganons Tower (Tile Room)', 'Ganon\'s Tower', ['Ganons Tower - Tile Room'], @@ -489,10 +434,13 @@ def create_inverted_regions(world, player): create_dungeon_region(world, player, 'Ganons Tower (Compass Room)', 'Ganon\'s Tower', ['Ganons Tower - Compass Room - Top Left', 'Ganons Tower - Compass Room - Top Right', 'Ganons Tower - Compass Room - Bottom Left', - 'Ganons Tower - Compass Room - Bottom Right'], ['Ganons Tower (Bottom) (East)']), + 'Ganons Tower - Compass Room - Bottom Right', + 'Ganons Tower - Conveyor Star Pits Pot Key'], + ['Ganons Tower (Bottom) (East)']), create_dungeon_region(world, player, 'Ganons Tower (Hookshot Room)', 'Ganon\'s Tower', ['Ganons Tower - DMs Room - Top Left', 'Ganons Tower - DMs Room - Top Right', - 'Ganons Tower - DMs Room - Bottom Left', 'Ganons Tower - DMs Room - Bottom Right'], + 'Ganons Tower - DMs Room - Bottom Left', 'Ganons Tower - DMs Room - Bottom Right', + 'Ganons Tower - Double Switch Pot Key'], ['Ganons Tower (Map Room)', 'Ganons Tower (Double Switch Room)']), create_dungeon_region(world, player, 'Ganons Tower (Map Room)', 'Ganon\'s Tower', ['Ganons Tower - Map Chest']), create_dungeon_region(world, player, 'Ganons Tower (Firesnake Room)', 'Ganon\'s Tower', @@ -501,21 +449,21 @@ def create_inverted_regions(world, player): ['Ganons Tower - Randomizer Room - Top Left', 'Ganons Tower - Randomizer Room - Top Right', 'Ganons Tower - Randomizer Room - Bottom Left', - 'Ganons Tower - Randomizer Room - Bottom Right'], ['Ganons Tower (Bottom) (West)']), + 'Ganons Tower - Randomizer Room - Bottom Right'], + ['Ganons Tower (Bottom) (West)']), create_dungeon_region(world, player, 'Ganons Tower (Bottom)', 'Ganon\'s Tower', ['Ganons Tower - Bob\'s Chest', 'Ganons Tower - Big Chest', 'Ganons Tower - Big Key Room - Left', 'Ganons Tower - Big Key Room - Right', 'Ganons Tower - Big Key Chest']), - create_dungeon_region(world, player, 'Ganons Tower (Top)', 'Ganon\'s Tower', None, - ['Ganons Tower Torch Rooms']), + create_dungeon_region(world, player, 'Ganons Tower (Top)', 'Ganon\'s Tower', None, ['Ganons Tower Torch Rooms']), create_dungeon_region(world, player, 'Ganons Tower (Before Moldorm)', 'Ganon\'s Tower', ['Ganons Tower - Mini Helmasaur Room - Left', 'Ganons Tower - Mini Helmasaur Room - Right', - 'Ganons Tower - Pre-Moldorm Chest'], ['Ganons Tower Moldorm Door']), - create_dungeon_region(world, player, 'Ganons Tower (Moldorm)', 'Ganon\'s Tower', None, - ['Ganons Tower Moldorm Gap']), - create_dungeon_region(world, player, 'Agahnim 2', 'Ganon\'s Tower', - ['Ganons Tower - Validation Chest', 'Agahnim 2'], None), + 'Ganons Tower - Pre-Moldorm Chest', 'Ganons Tower - Mini Helmasaur Key Drop'], + ['Ganons Tower Moldorm Door']), + create_dungeon_region(world, player, 'Ganons Tower (Moldorm)', 'Ganon\'s Tower', None, ['Ganons Tower Moldorm Gap']), + + create_dungeon_region(world, player, 'Agahnim 2', 'Ganon\'s Tower', ['Ganons Tower - Validation Chest', 'Agahnim 2'], None), create_cave_region(world, player, 'Pyramid', 'a drop\'s exit', ['Ganon'], ['Ganon Drop']), create_cave_region(world, player, 'Bottom of Pyramid', 'a drop\'s exit', None, ['Pyramid Exit']), create_dw_region(world, player, 'Pyramid Ledge', None, ['Pyramid Drop']), # houlihan room exits here in inverted diff --git a/worlds/alttp/ItemPool.py b/worlds/alttp/ItemPool.py index 56eb355837d4..f8fdd55ef657 100644 --- a/worlds/alttp/ItemPool.py +++ b/worlds/alttp/ItemPool.py @@ -12,6 +12,7 @@ from .Items import ItemFactory, GetBeemizerItem from .Options import smallkey_shuffle, compass_shuffle, bigkey_shuffle, map_shuffle, LTTPBosses from .StateHelpers import has_triforce_pieces, has_melee_weapon +from .Regions import key_drop_data # This file sets the item pools for various modes. Timed modes and triforce hunt are enforced first, and then extra items are specified per mode to fill in the remaining space. # Some basic items that various modes require are placed here, including pendants and crystals. Medallion requirements for the two relevant entrances are also decided. @@ -80,7 +81,7 @@ basicglove=basicgloves, alwaysitems=alwaysitems, legacyinsanity=legacyinsanity, - universal_keys=['Small Key (Universal)'] * 28, + universal_keys=['Small Key (Universal)'] * 29, extras=[easyfirst15extra, easysecond15extra, easythird10extra, easyfourth5extra, easyfinal25extra], progressive_sword_limit=8, progressive_shield_limit=6, @@ -112,7 +113,7 @@ basicglove=basicgloves, alwaysitems=alwaysitems, legacyinsanity=legacyinsanity, - universal_keys=['Small Key (Universal)'] * 18 + ['Rupees (20)'] * 10, + universal_keys=['Small Key (Universal)'] * 19 + ['Rupees (20)'] * 10, extras=[normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra], progressive_sword_limit=4, progressive_shield_limit=3, @@ -144,7 +145,7 @@ basicglove=basicgloves, alwaysitems=alwaysitems, legacyinsanity=legacyinsanity, - universal_keys=['Small Key (Universal)'] * 12 + ['Rupees (5)'] * 16, + universal_keys=['Small Key (Universal)'] * 13 + ['Rupees (5)'] * 16, extras=[normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra], progressive_sword_limit=3, progressive_shield_limit=2, @@ -176,7 +177,7 @@ basicglove=basicgloves, alwaysitems=alwaysitems, legacyinsanity=legacyinsanity, - universal_keys=['Small Key (Universal)'] * 12 + ['Rupees (5)'] * 16, + universal_keys=['Small Key (Universal)'] * 13 + ['Rupees (5)'] * 16, extras=[normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra], progressive_sword_limit=2, progressive_shield_limit=1, @@ -212,7 +213,7 @@ basicglove=['Nothing'] * 2, alwaysitems=['Ice Rod'] + ['Nothing'] * 19, legacyinsanity=['Nothing'] * 2, - universal_keys=['Nothing'] * 28, + universal_keys=['Nothing'] * 29, extras=[['Nothing'] * 15, ['Nothing'] * 15, ['Nothing'] * 10, ['Nothing'] * 5, ['Nothing'] * 25], progressive_sword_limit=difficulties[diff].progressive_sword_limit, progressive_shield_limit=difficulties[diff].progressive_shield_limit, @@ -281,7 +282,6 @@ def generate_itempool(world): itempool.extend(['Arrows (10)'] * 7) if multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal: itempool.extend(itemdiff.universal_keys) - itempool.append('Small Key (Universal)') for item in itempool: multiworld.push_precollected(ItemFactory(item, player)) @@ -374,11 +374,38 @@ def generate_itempool(world): dungeon_items = [item for item in get_dungeon_item_pool_player(world) if item.name not in multiworld.worlds[player].dungeon_local_item_names] - dungeon_item_replacements = difficulties[multiworld.difficulty[player]].extras[0]\ - + difficulties[multiworld.difficulty[player]].extras[1]\ - + difficulties[multiworld.difficulty[player]].extras[2]\ - + difficulties[multiworld.difficulty[player]].extras[3]\ - + difficulties[multiworld.difficulty[player]].extras[4] + + for key_loc in key_drop_data: + key_data = key_drop_data[key_loc] + drop_item = ItemFactory(key_data[3], player) + if multiworld.goal[player] == 'icerodhunt' or not multiworld.key_drop_shuffle[player]: + if drop_item in dungeon_items: + dungeon_items.remove(drop_item) + else: + dungeon = drop_item.name.split("(")[1].split(")")[0] + if multiworld.mode[player] == 'inverted': + if dungeon == "Agahnims Tower": + dungeon = "Inverted Agahnims Tower" + if dungeon == "Ganons Tower": + dungeon = "Inverted Ganons Tower" + if drop_item in world.dungeons[dungeon].small_keys: + world.dungeons[dungeon].small_keys.remove(drop_item) + elif world.dungeons[dungeon].big_key is not None and world.dungeons[dungeon].big_key == drop_item: + world.dungeons[dungeon].big_key = None + if not multiworld.key_drop_shuffle[player]: + # key drop item was removed from the pool because key drop shuffle is off + # and it will now place the removed key into its original location + loc = multiworld.get_location(key_loc, player) + loc.place_locked_item(drop_item) + loc.address = None + elif multiworld.goal[player] == 'icerodhunt': + # key drop item removed because of icerodhunt + multiworld.itempool.append(ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player)) + multiworld.push_precollected(drop_item) + elif "Small" in key_data[3] and multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal: + # key drop shuffle and universal keys are on. Add universal keys in place of key drop keys. + multiworld.itempool.append(ItemFactory(GetBeemizerItem(world, player, 'Small Key (Universal)'), player)) + dungeon_item_replacements = sum(difficulties[multiworld.difficulty[player]].extras, []) * 2 multiworld.random.shuffle(dungeon_item_replacements) if multiworld.goal[player] == 'icerodhunt': for item in dungeon_items: @@ -391,7 +418,7 @@ def generate_itempool(world): or (multiworld.bigkey_shuffle[player] == bigkey_shuffle.option_start_with and item.type == 'BigKey') or (multiworld.compass_shuffle[player] == compass_shuffle.option_start_with and item.type == 'Compass') or (multiworld.map_shuffle[player] == map_shuffle.option_start_with and item.type == 'Map')): - dungeon_items.remove(item) + dungeon_items.pop(x) multiworld.push_precollected(item) multiworld.itempool.append(ItemFactory(dungeon_item_replacements.pop(), player)) multiworld.itempool.extend([item for item in dungeon_items]) @@ -639,14 +666,27 @@ def place_item(loc, item): pool = ['Rupees (5)' if item in replace else item for item in pool] if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal: pool.extend(diff.universal_keys) - item_to_place = 'Small Key (Universal)' if goal != 'icerodhunt' else 'Nothing' if mode == 'standard': - key_location = world.random.choice( - ['Secret Passage', 'Hyrule Castle - Boomerang Chest', 'Hyrule Castle - Map Chest', - 'Hyrule Castle - Zelda\'s Chest', 'Sewers - Dark Cross']) - place_item(key_location, item_to_place) - else: - pool.extend([item_to_place]) + if world.key_drop_shuffle[player] and world.goal[player] != 'icerodhunt': + key_locations = ['Secret Passage', 'Hyrule Castle - Map Guard Key Drop'] + key_location = world.random.choice(key_locations) + key_locations.remove(key_location) + place_item(key_location, "Small Key (Universal)") + key_locations += ['Hyrule Castle - Boomerang Guard Key Drop', 'Hyrule Castle - Boomerang Chest', + 'Hyrule Castle - Map Chest'] + key_location = world.random.choice(key_locations) + key_locations.remove(key_location) + place_item(key_location, "Small Key (Universal)") + key_locations += ['Hyrule Castle - Big Key Drop', 'Hyrule Castle - Zelda\'s Chest', 'Sewers - Dark Cross'] + key_location = world.random.choice(key_locations) + key_locations.remove(key_location) + place_item(key_location, "Small Key (Universal)") + key_locations += ['Sewers - Key Rat Key Drop'] + key_location = world.random.choice(key_locations) + place_item(key_location, "Small Key (Universal)") + pool = pool[:-3] + if world.key_drop_shuffle[player]: + pass # pool.extend([item_to_place] * (len(key_drop_data) - 1)) return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon, additional_pieces_to_place) @@ -799,7 +839,9 @@ def place_item(loc, item): pool.extend(['Moon Pearl'] * customitemarray[28]) if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal: - itemtotal = itemtotal - 28 # Corrects for small keys not being in item pool in universal mode + itemtotal = itemtotal - 28 # Corrects for small keys not being in item pool in universal Mode + if world.key_drop_shuffle[player]: + itemtotal = itemtotal - (len(key_drop_data) - 1) if itemtotal < total_items_to_place: pool.extend(['Nothing'] * (total_items_to_place - itemtotal)) logging.warning(f"Pool was filled up with {total_items_to_place - itemtotal} Nothing's for player {player}") diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index b4b0958ac23f..0f35be7459a3 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -101,6 +101,11 @@ class map_shuffle(DungeonItem): display_name = "Map Shuffle" +class key_drop_shuffle(Toggle): + """Shuffle keys found in pots and dropped from killed enemies.""" + display_name = "Key Drop Shuffle" + default = False + class Crystals(Range): range_start = 0 range_end = 7 @@ -432,6 +437,7 @@ class AllowCollect(Toggle): "open_pyramid": OpenPyramid, "bigkey_shuffle": bigkey_shuffle, "smallkey_shuffle": smallkey_shuffle, + "key_drop_shuffle": key_drop_shuffle, "compass_shuffle": compass_shuffle, "map_shuffle": map_shuffle, "progressive": Progressive, diff --git a/worlds/alttp/Regions.py b/worlds/alttp/Regions.py index 9badbd877422..8311bc32694e 100644 --- a/worlds/alttp/Regions.py +++ b/worlds/alttp/Regions.py @@ -14,42 +14,26 @@ def create_regions(world, player): world.regions += [ create_lw_region(world, player, 'Menu', None, ['Links House S&Q', 'Sanctuary S&Q', 'Old Man S&Q']), create_lw_region(world, player, 'Light World', ['Mushroom', 'Bottle Merchant', 'Flute Spot', 'Sunken Treasure', - 'Purple Chest', 'Flute Activation Spot'], - ["Blinds Hideout", "Hyrule Castle Secret Entrance Drop", 'Zoras River', - 'Kings Grave Outer Rocks', 'Dam', - 'Links House', 'Tavern North', 'Chicken House', 'Aginahs Cave', 'Sahasrahlas Hut', - 'Kakariko Well Drop', 'Kakariko Well Cave', - 'Blacksmiths Hut', 'Bat Cave Drop Ledge', 'Bat Cave Cave', 'Sick Kids House', 'Hobo Bridge', - 'Lost Woods Hideout Drop', 'Lost Woods Hideout Stump', - 'Lumberjack Tree Tree', 'Lumberjack Tree Cave', 'Mini Moldorm Cave', 'Ice Rod Cave', - 'Lake Hylia Central Island Pier', - 'Bonk Rock Cave', 'Library', 'Potion Shop', 'Two Brothers House (East)', - 'Desert Palace Stairs', 'Eastern Palace', 'Master Sword Meadow', - 'Sanctuary', 'Sanctuary Grave', 'Death Mountain Entrance Rock', 'Flute Spot 1', - 'Dark Desert Teleporter', 'East Hyrule Teleporter', 'South Hyrule Teleporter', - 'Kakariko Teleporter', - 'Elder House (East)', 'Elder House (West)', 'North Fairy Cave', 'North Fairy Cave Drop', - 'Lost Woods Gamble', 'Snitch Lady (East)', 'Snitch Lady (West)', 'Tavern (Front)', - 'Bush Covered House', 'Light World Bomb Hut', 'Kakariko Shop', 'Long Fairy Cave', - 'Good Bee Cave', '20 Rupee Cave', 'Cave Shop (Lake Hylia)', 'Waterfall of Wishing', - 'Hyrule Castle Main Gate', - 'Bonk Fairy (Light)', '50 Rupee Cave', 'Fortune Teller (Light)', 'Lake Hylia Fairy', - 'Light Hype Fairy', 'Desert Fairy', 'Lumberjack House', 'Lake Hylia Fortune Teller', - 'Kakariko Gamble Game', 'Top of Pyramid']), - create_lw_region(world, player, 'Death Mountain Entrance', None, - ['Old Man Cave (West)', 'Death Mountain Entrance Drop']), - create_lw_region(world, player, 'Lake Hylia Central Island', None, - ['Capacity Upgrade', 'Lake Hylia Central Island Teleporter']), + 'Purple Chest', 'Flute Activation Spot'], + ["Blinds Hideout", "Hyrule Castle Secret Entrance Drop", 'Zoras River', 'Kings Grave Outer Rocks', 'Dam', + 'Links House', 'Tavern North', 'Chicken House', 'Aginahs Cave', 'Sahasrahlas Hut', 'Kakariko Well Drop', 'Kakariko Well Cave', + 'Blacksmiths Hut', 'Bat Cave Drop Ledge', 'Bat Cave Cave', 'Sick Kids House', 'Hobo Bridge', 'Lost Woods Hideout Drop', 'Lost Woods Hideout Stump', + 'Lumberjack Tree Tree', 'Lumberjack Tree Cave', 'Mini Moldorm Cave', 'Ice Rod Cave', 'Lake Hylia Central Island Pier', + 'Bonk Rock Cave', 'Library', 'Potion Shop', 'Two Brothers House (East)', 'Desert Palace Stairs', 'Eastern Palace', 'Master Sword Meadow', + 'Sanctuary', 'Sanctuary Grave', 'Death Mountain Entrance Rock', 'Flute Spot 1', 'Dark Desert Teleporter', 'East Hyrule Teleporter', 'South Hyrule Teleporter', 'Kakariko Teleporter', + 'Elder House (East)', 'Elder House (West)', 'North Fairy Cave', 'North Fairy Cave Drop', 'Lost Woods Gamble', 'Snitch Lady (East)', 'Snitch Lady (West)', 'Tavern (Front)', + 'Bush Covered House', 'Light World Bomb Hut', 'Kakariko Shop', 'Long Fairy Cave', 'Good Bee Cave', '20 Rupee Cave', 'Cave Shop (Lake Hylia)', 'Waterfall of Wishing', 'Hyrule Castle Main Gate', + 'Bonk Fairy (Light)', '50 Rupee Cave', 'Fortune Teller (Light)', 'Lake Hylia Fairy', 'Light Hype Fairy', 'Desert Fairy', 'Lumberjack House', 'Lake Hylia Fortune Teller', 'Kakariko Gamble Game', 'Top of Pyramid']), + create_lw_region(world, player, 'Death Mountain Entrance', None, ['Old Man Cave (West)', 'Death Mountain Entrance Drop']), + create_lw_region(world, player, 'Lake Hylia Central Island', None, ['Capacity Upgrade', 'Lake Hylia Central Island Teleporter']), create_cave_region(world, player, 'Blinds Hideout', 'a bounty of five items', ["Blind\'s Hideout - Top", - "Blind\'s Hideout - Left", - "Blind\'s Hideout - Right", - "Blind\'s Hideout - Far Left", - "Blind\'s Hideout - Far Right"]), - create_cave_region(world, player, 'Hyrule Castle Secret Entrance', 'a drop\'s exit', - ['Link\'s Uncle', 'Secret Passage'], ['Hyrule Castle Secret Entrance Exit']), + "Blind\'s Hideout - Left", + "Blind\'s Hideout - Right", + "Blind\'s Hideout - Far Left", + "Blind\'s Hideout - Far Right"]), + create_cave_region(world, player, 'Hyrule Castle Secret Entrance', 'a drop\'s exit', ['Link\'s Uncle', 'Secret Passage'], ['Hyrule Castle Secret Entrance Exit']), create_lw_region(world, player, 'Zoras River', ['King Zora', 'Zora\'s Ledge']), - create_cave_region(world, player, 'Waterfall of Wishing', 'a cave with two chests', - ['Waterfall Fairy - Left', 'Waterfall Fairy - Right']), + create_cave_region(world, player, 'Waterfall of Wishing', 'a cave with two chests', ['Waterfall Fairy - Left', 'Waterfall Fairy - Right']), create_lw_region(world, player, 'Kings Grave Area', None, ['Kings Grave', 'Kings Grave Inner Rocks']), create_cave_region(world, player, 'Kings Grave', 'a cave with a chest', ['King\'s Tomb']), create_cave_region(world, player, 'North Fairy Cave', 'a drop\'s exit', None, ['North Fairy Cave Exit']), @@ -57,8 +41,7 @@ def create_regions(world, player): create_cave_region(world, player, 'Links House', 'your house', ['Link\'s House'], ['Links House Exit']), create_cave_region(world, player, 'Chris Houlihan Room', 'I AM ERROR', None, ['Chris Houlihan Room Exit']), create_cave_region(world, player, 'Tavern', 'the tavern', ['Kakariko Tavern']), - create_cave_region(world, player, 'Elder House', 'a connector', None, - ['Elder House Exit (East)', 'Elder House Exit (West)']), + create_cave_region(world, player, 'Elder House', 'a connector', None, ['Elder House Exit (East)', 'Elder House Exit (West)']), create_cave_region(world, player, 'Snitch Lady (East)', 'a boring house'), create_cave_region(world, player, 'Snitch Lady (West)', 'a boring house'), create_cave_region(world, player, 'Bush Covered House', 'the grass man'), @@ -79,12 +62,9 @@ def create_regions(world, player): create_cave_region(world, player, 'Dark Death Mountain Healer Fairy', 'a fairy fountain'), create_cave_region(world, player, 'Chicken House', 'a house with a chest', ['Chicken House']), create_cave_region(world, player, 'Aginahs Cave', 'a cave with a chest', ['Aginah\'s Cave']), - create_cave_region(world, player, 'Sahasrahlas Hut', 'Sahasrahla', - ['Sahasrahla\'s Hut - Left', 'Sahasrahla\'s Hut - Middle', 'Sahasrahla\'s Hut - Right', - 'Sahasrahla']), - create_cave_region(world, player, 'Kakariko Well (top)', 'a drop\'s exit', - ['Kakariko Well - Top', 'Kakariko Well - Left', 'Kakariko Well - Middle', - 'Kakariko Well - Right', 'Kakariko Well - Bottom'], ['Kakariko Well (top to bottom)']), + create_cave_region(world, player, 'Sahasrahlas Hut', 'Sahasrahla', ['Sahasrahla\'s Hut - Left', 'Sahasrahla\'s Hut - Middle', 'Sahasrahla\'s Hut - Right', 'Sahasrahla']), + create_cave_region(world, player, 'Kakariko Well (top)', 'a drop\'s exit', ['Kakariko Well - Top', 'Kakariko Well - Left', 'Kakariko Well - Middle', + 'Kakariko Well - Right', 'Kakariko Well - Bottom'], ['Kakariko Well (top to bottom)']), create_cave_region(world, player, 'Kakariko Well (bottom)', 'a drop\'s exit', None, ['Kakariko Well Exit']), create_cave_region(world, player, 'Blacksmiths Hut', 'the smith', ['Blacksmith', 'Missing Smith']), create_lw_region(world, player, 'Bat Cave Drop Ledge', None, ['Bat Cave Drop']), @@ -92,12 +72,9 @@ def create_regions(world, player): create_cave_region(world, player, 'Bat Cave (left)', 'a drop\'s exit', None, ['Bat Cave Exit']), create_cave_region(world, player, 'Sick Kids House', 'the sick kid', ['Sick Kid']), create_lw_region(world, player, 'Hobo Bridge', ['Hobo']), - create_cave_region(world, player, 'Lost Woods Hideout (top)', 'a drop\'s exit', ['Lost Woods Hideout'], - ['Lost Woods Hideout (top to bottom)']), - create_cave_region(world, player, 'Lost Woods Hideout (bottom)', 'a drop\'s exit', None, - ['Lost Woods Hideout Exit']), - create_cave_region(world, player, 'Lumberjack Tree (top)', 'a drop\'s exit', ['Lumberjack Tree'], - ['Lumberjack Tree (top to bottom)']), + create_cave_region(world, player, 'Lost Woods Hideout (top)', 'a drop\'s exit', ['Lost Woods Hideout'], ['Lost Woods Hideout (top to bottom)']), + create_cave_region(world, player, 'Lost Woods Hideout (bottom)', 'a drop\'s exit', None, ['Lost Woods Hideout Exit']), + create_cave_region(world, player, 'Lumberjack Tree (top)', 'a drop\'s exit', ['Lumberjack Tree'], ['Lumberjack Tree (top to bottom)']), create_cave_region(world, player, 'Lumberjack Tree (bottom)', 'a drop\'s exit', None, ['Lumberjack Tree Exit']), create_lw_region(world, player, 'Cave 45 Ledge', None, ['Cave 45']), create_cave_region(world, player, 'Cave 45', 'a cave with an item', ['Cave 45']), @@ -105,9 +82,8 @@ def create_regions(world, player): create_cave_region(world, player, 'Graveyard Cave', 'a cave with an item', ['Graveyard Cave']), create_cave_region(world, player, 'Checkerboard Cave', 'a cave with an item', ['Checkerboard Cave']), create_cave_region(world, player, 'Long Fairy Cave', 'a fairy fountain'), - create_cave_region(world, player, 'Mini Moldorm Cave', 'a bounty of five items', - ['Mini Moldorm Cave - Far Left', 'Mini Moldorm Cave - Left', 'Mini Moldorm Cave - Right', - 'Mini Moldorm Cave - Far Right', 'Mini Moldorm Cave - Generous Guy']), + create_cave_region(world, player, 'Mini Moldorm Cave', 'a bounty of five items', ['Mini Moldorm Cave - Far Left', 'Mini Moldorm Cave - Left', 'Mini Moldorm Cave - Right', + 'Mini Moldorm Cave - Far Right', 'Mini Moldorm Cave - Generous Guy']), create_cave_region(world, player, 'Ice Rod Cave', 'a cave with a chest', ['Ice Rod Cave']), create_cave_region(world, player, 'Good Bee Cave', 'a cold bee'), create_cave_region(world, player, '20 Rupee Cave', 'a cave with some cash'), @@ -119,91 +95,56 @@ def create_regions(world, player): create_cave_region(world, player, 'Potion Shop', 'the potion shop', ['Potion Shop']), create_lw_region(world, player, 'Lake Hylia Island', ['Lake Hylia Island']), create_cave_region(world, player, 'Capacity Upgrade', 'the queen of fairies'), - create_cave_region(world, player, 'Two Brothers House', 'a connector', None, - ['Two Brothers House Exit (East)', 'Two Brothers House Exit (West)']), + create_cave_region(world, player, 'Two Brothers House', 'a connector', None, ['Two Brothers House Exit (East)', 'Two Brothers House Exit (West)']), create_lw_region(world, player, 'Maze Race Ledge', ['Maze Race'], ['Two Brothers House (West)']), create_cave_region(world, player, '50 Rupee Cave', 'a cave with some cash'), - create_lw_region(world, player, 'Desert Ledge', ['Desert Ledge'], - ['Desert Palace Entrance (North) Rocks', 'Desert Palace Entrance (West)']), + create_lw_region(world, player, 'Desert Ledge', ['Desert Ledge'], ['Desert Palace Entrance (North) Rocks', 'Desert Palace Entrance (West)']), create_lw_region(world, player, 'Desert Ledge (Northeast)', None, ['Checkerboard Cave']), create_lw_region(world, player, 'Desert Palace Stairs', None, ['Desert Palace Entrance (South)']), - create_lw_region(world, player, 'Desert Palace Lone Stairs', None, - ['Desert Palace Stairs Drop', 'Desert Palace Entrance (East)']), - create_lw_region(world, player, 'Desert Palace Entrance (North) Spot', None, - ['Desert Palace Entrance (North)', 'Desert Ledge Return Rocks']), - create_dungeon_region(world, player, 'Desert Palace Main (Outer)', 'Desert Palace', - ['Desert Palace - Big Chest', 'Desert Palace - Torch', 'Desert Palace - Map Chest'], - ['Desert Palace Pots (Outer)', 'Desert Palace Exit (West)', 'Desert Palace Exit (East)', - 'Desert Palace East Wing']), - create_dungeon_region(world, player, 'Desert Palace Main (Inner)', 'Desert Palace', None, - ['Desert Palace Exit (South)', 'Desert Palace Pots (Inner)']), - create_dungeon_region(world, player, 'Desert Palace East', 'Desert Palace', - ['Desert Palace - Compass Chest', 'Desert Palace - Big Key Chest']), - create_dungeon_region(world, player, 'Desert Palace North', 'Desert Palace', - ['Desert Palace - Boss', 'Desert Palace - Prize'], ['Desert Palace Exit (North)']), - create_dungeon_region(world, player, 'Eastern Palace', 'Eastern Palace', - ['Eastern Palace - Compass Chest', 'Eastern Palace - Big Chest', - 'Eastern Palace - Cannonball Chest', - 'Eastern Palace - Big Key Chest', 'Eastern Palace - Map Chest', 'Eastern Palace - Boss', - 'Eastern Palace - Prize'], ['Eastern Palace Exit']), + create_lw_region(world, player, 'Desert Palace Lone Stairs', None, ['Desert Palace Stairs Drop', 'Desert Palace Entrance (East)']), + create_lw_region(world, player, 'Desert Palace Entrance (North) Spot', None, ['Desert Palace Entrance (North)', 'Desert Ledge Return Rocks']), + create_dungeon_region(world, player, 'Desert Palace Main (Outer)', 'Desert Palace', ['Desert Palace - Big Chest', 'Desert Palace - Torch', 'Desert Palace - Map Chest'], + ['Desert Palace Pots (Outer)', 'Desert Palace Exit (West)', 'Desert Palace Exit (East)', 'Desert Palace East Wing']), + create_dungeon_region(world, player, 'Desert Palace Main (Inner)', 'Desert Palace', None, ['Desert Palace Exit (South)', 'Desert Palace Pots (Inner)']), + create_dungeon_region(world, player, 'Desert Palace East', 'Desert Palace', ['Desert Palace - Compass Chest', 'Desert Palace - Big Key Chest']), + create_dungeon_region(world, player, 'Desert Palace North', 'Desert Palace', ['Desert Palace - Desert Tiles 1 Pot Key', 'Desert Palace - Beamos Hall Pot Key', 'Desert Palace - Desert Tiles 2 Pot Key', + 'Desert Palace - Boss', 'Desert Palace - Prize'], ['Desert Palace Exit (North)']), + create_dungeon_region(world, player, 'Eastern Palace', 'Eastern Palace', ['Eastern Palace - Compass Chest', 'Eastern Palace - Big Chest', 'Eastern Palace - Cannonball Chest', + 'Eastern Palace - Dark Square Pot Key', 'Eastern Palace - Dark Eyegore Key Drop', 'Eastern Palace - Big Key Chest', + 'Eastern Palace - Map Chest', 'Eastern Palace - Boss', 'Eastern Palace - Prize'], ['Eastern Palace Exit']), create_lw_region(world, player, 'Master Sword Meadow', ['Master Sword Pedestal']), create_cave_region(world, player, 'Lost Woods Gamble', 'a game of chance'), - create_lw_region(world, player, 'Hyrule Castle Courtyard', None, - ['Hyrule Castle Secret Entrance Stairs', 'Hyrule Castle Entrance (South)']), - create_lw_region(world, player, 'Hyrule Castle Ledge', None, - ['Hyrule Castle Entrance (East)', 'Hyrule Castle Entrance (West)', 'Agahnims Tower', - 'Hyrule Castle Ledge Courtyard Drop']), - create_dungeon_region(world, player, 'Hyrule Castle', 'Hyrule Castle', - ['Hyrule Castle - Boomerang Chest', 'Hyrule Castle - Map Chest', - 'Hyrule Castle - Zelda\'s Chest'], - ['Hyrule Castle Exit (East)', 'Hyrule Castle Exit (West)', 'Hyrule Castle Exit (South)', - 'Throne Room']), + create_lw_region(world, player, 'Hyrule Castle Courtyard', None, ['Hyrule Castle Secret Entrance Stairs', 'Hyrule Castle Entrance (South)']), + create_lw_region(world, player, 'Hyrule Castle Ledge', None, ['Hyrule Castle Entrance (East)', 'Hyrule Castle Entrance (West)', 'Agahnims Tower', 'Hyrule Castle Ledge Courtyard Drop']), + create_dungeon_region(world, player, 'Hyrule Castle', 'Hyrule Castle', ['Hyrule Castle - Boomerang Chest', 'Hyrule Castle - Map Chest', 'Hyrule Castle - Zelda\'s Chest', + 'Hyrule Castle - Map Guard Key Drop', 'Hyrule Castle - Boomerang Guard Key Drop', 'Hyrule Castle - Big Key Drop'], + ['Hyrule Castle Exit (East)', 'Hyrule Castle Exit (West)', 'Hyrule Castle Exit (South)', 'Throne Room']), create_dungeon_region(world, player, 'Sewer Drop', 'a drop\'s exit', None, ['Sewer Drop']), # This exists only to be referenced for access checks - create_dungeon_region(world, player, 'Sewers (Dark)', 'a drop\'s exit', ['Sewers - Dark Cross'], - ['Sewers Door']), - create_dungeon_region(world, player, 'Sewers', 'a drop\'s exit', - ['Sewers - Secret Room - Left', 'Sewers - Secret Room - Middle', - 'Sewers - Secret Room - Right'], ['Sanctuary Push Door', 'Sewers Back Door']), + create_dungeon_region(world, player, 'Sewers (Dark)', 'a drop\'s exit', ['Sewers - Dark Cross', 'Sewers - Key Rat Key Drop'], ['Sewers Door']), + create_dungeon_region(world, player, 'Sewers', 'a drop\'s exit', ['Sewers - Secret Room - Left', 'Sewers - Secret Room - Middle', + 'Sewers - Secret Room - Right'], ['Sanctuary Push Door', 'Sewers Back Door']), create_dungeon_region(world, player, 'Sanctuary', 'a drop\'s exit', ['Sanctuary'], ['Sanctuary Exit']), - create_dungeon_region(world, player, 'Agahnims Tower', 'Castle Tower', - ['Castle Tower - Room 03', 'Castle Tower - Dark Maze'], - ['Agahnim 1', 'Agahnims Tower Exit']), + create_dungeon_region(world, player, 'Agahnims Tower', 'Castle Tower', ['Castle Tower - Room 03', 'Castle Tower - Dark Maze', 'Castle Tower - Dark Archer Key Drop', 'Castle Tower - Circle of Pots Key Drop'], ['Agahnim 1', 'Agahnims Tower Exit']), create_dungeon_region(world, player, 'Agahnim 1', 'Castle Tower', ['Agahnim 1'], None), - create_cave_region(world, player, 'Old Man Cave', 'a connector', ['Old Man'], - ['Old Man Cave Exit (East)', 'Old Man Cave Exit (West)']), - create_cave_region(world, player, 'Old Man House', 'a connector', None, - ['Old Man House Exit (Bottom)', 'Old Man House Front to Back']), - create_cave_region(world, player, 'Old Man House Back', 'a connector', None, - ['Old Man House Exit (Top)', 'Old Man House Back to Front']), - create_lw_region(world, player, 'Death Mountain', None, - ['Old Man Cave (East)', 'Old Man House (Bottom)', 'Old Man House (Top)', - 'Death Mountain Return Cave (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave Peak', - 'Spectacle Rock Cave (Bottom)', 'Broken Bridge (West)', 'Death Mountain Teleporter']), - create_cave_region(world, player, 'Death Mountain Return Cave', 'a connector', None, - ['Death Mountain Return Cave Exit (West)', 'Death Mountain Return Cave Exit (East)']), - create_lw_region(world, player, 'Death Mountain Return Ledge', None, - ['Death Mountain Return Ledge Drop', 'Death Mountain Return Cave (West)']), - create_cave_region(world, player, 'Spectacle Rock Cave (Top)', 'a connector', ['Spectacle Rock Cave'], - ['Spectacle Rock Cave Drop', 'Spectacle Rock Cave Exit (Top)']), - create_cave_region(world, player, 'Spectacle Rock Cave (Bottom)', 'a connector', None, - ['Spectacle Rock Cave Exit']), - create_cave_region(world, player, 'Spectacle Rock Cave (Peak)', 'a connector', None, - ['Spectacle Rock Cave Peak Drop', 'Spectacle Rock Cave Exit (Peak)']), - create_lw_region(world, player, 'East Death Mountain (Bottom)', None, - ['Broken Bridge (East)', 'Paradox Cave (Bottom)', 'Paradox Cave (Middle)', - 'East Death Mountain Teleporter', 'Hookshot Fairy', 'Fairy Ascension Rocks', - 'Spiral Cave (Bottom)']), + create_cave_region(world, player, 'Old Man Cave', 'a connector', ['Old Man'], ['Old Man Cave Exit (East)', 'Old Man Cave Exit (West)']), + create_cave_region(world, player, 'Old Man House', 'a connector', None, ['Old Man House Exit (Bottom)', 'Old Man House Front to Back']), + create_cave_region(world, player, 'Old Man House Back', 'a connector', None, ['Old Man House Exit (Top)', 'Old Man House Back to Front']), + create_lw_region(world, player, 'Death Mountain', None, ['Old Man Cave (East)', 'Old Man House (Bottom)', 'Old Man House (Top)', 'Death Mountain Return Cave (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave Peak', 'Spectacle Rock Cave (Bottom)', 'Broken Bridge (West)', 'Death Mountain Teleporter']), + create_cave_region(world, player, 'Death Mountain Return Cave', 'a connector', None, ['Death Mountain Return Cave Exit (West)', 'Death Mountain Return Cave Exit (East)']), + create_lw_region(world, player, 'Death Mountain Return Ledge', None, ['Death Mountain Return Ledge Drop', 'Death Mountain Return Cave (West)']), + create_cave_region(world, player, 'Spectacle Rock Cave (Top)', 'a connector', ['Spectacle Rock Cave'], ['Spectacle Rock Cave Drop', 'Spectacle Rock Cave Exit (Top)']), + create_cave_region(world, player, 'Spectacle Rock Cave (Bottom)', 'a connector', None, ['Spectacle Rock Cave Exit']), + create_cave_region(world, player, 'Spectacle Rock Cave (Peak)', 'a connector', None, ['Spectacle Rock Cave Peak Drop', 'Spectacle Rock Cave Exit (Peak)']), + create_lw_region(world, player, 'East Death Mountain (Bottom)', None, ['Broken Bridge (East)', 'Paradox Cave (Bottom)', 'Paradox Cave (Middle)', 'East Death Mountain Teleporter', 'Hookshot Fairy', 'Fairy Ascension Rocks', 'Spiral Cave (Bottom)']), create_cave_region(world, player, 'Hookshot Fairy', 'fairies deep in a cave'), - create_cave_region(world, player, 'Paradox Cave Front', 'a connector', None, - ['Paradox Cave Push Block Reverse', 'Paradox Cave Exit (Bottom)', - 'Light World Death Mountain Shop']), + create_cave_region(world, player, 'Paradox Cave Front', 'a connector', None, ['Paradox Cave Push Block Reverse', 'Paradox Cave Exit (Bottom)', 'Light World Death Mountain Shop']), create_cave_region(world, player, 'Paradox Cave Chest Area', 'a connector', ['Paradox Cave Lower - Far Left', - 'Paradox Cave Lower - Left', - 'Paradox Cave Lower - Right', - 'Paradox Cave Lower - Far Right', - 'Paradox Cave Lower - Middle', - 'Paradox Cave Upper - Left', - 'Paradox Cave Upper - Right'], + 'Paradox Cave Lower - Left', + 'Paradox Cave Lower - Right', + 'Paradox Cave Lower - Far Right', + 'Paradox Cave Lower - Middle', + 'Paradox Cave Upper - Left', + 'Paradox Cave Upper - Right'], ['Paradox Cave Push Block', 'Paradox Cave Bomb Jump']), create_cave_region(world, player, 'Paradox Cave', 'a connector', None, ['Paradox Cave Exit (Middle)', 'Paradox Cave Exit (Top)', 'Paradox Cave Drop']), @@ -342,162 +283,98 @@ def create_regions(world, player): create_lw_region(world, player, 'Mimic Cave Ledge', None, ['Mimic Cave']), create_cave_region(world, player, 'Mimic Cave', 'Mimic Cave', ['Mimic Cave']), - create_dungeon_region(world, player, 'Swamp Palace (Entrance)', 'Swamp Palace', None, - ['Swamp Palace Moat', 'Swamp Palace Exit']), - create_dungeon_region(world, player, 'Swamp Palace (First Room)', 'Swamp Palace', ['Swamp Palace - Entrance'], - ['Swamp Palace Small Key Door']), - create_dungeon_region(world, player, 'Swamp Palace (Starting Area)', 'Swamp Palace', - ['Swamp Palace - Map Chest'], ['Swamp Palace (Center)']), - create_dungeon_region(world, player, 'Swamp Palace (Center)', 'Swamp Palace', - ['Swamp Palace - Big Chest', 'Swamp Palace - Compass Chest', - 'Swamp Palace - Big Key Chest', 'Swamp Palace - West Chest'], ['Swamp Palace (North)']), - create_dungeon_region(world, player, 'Swamp Palace (North)', 'Swamp Palace', - ['Swamp Palace - Flooded Room - Left', 'Swamp Palace - Flooded Room - Right', - 'Swamp Palace - Waterfall Room', 'Swamp Palace - Boss', 'Swamp Palace - Prize']), - create_dungeon_region(world, player, 'Thieves Town (Entrance)', 'Thieves\' Town', - ['Thieves\' Town - Big Key Chest', - 'Thieves\' Town - Map Chest', - 'Thieves\' Town - Compass Chest', - 'Thieves\' Town - Ambush Chest'], ['Thieves Town Big Key Door', 'Thieves Town Exit']), + create_dungeon_region(world, player, 'Swamp Palace (Entrance)', 'Swamp Palace', None, ['Swamp Palace Moat', 'Swamp Palace Exit']), + create_dungeon_region(world, player, 'Swamp Palace (First Room)', 'Swamp Palace', ['Swamp Palace - Entrance'], ['Swamp Palace Small Key Door']), + create_dungeon_region(world, player, 'Swamp Palace (Starting Area)', 'Swamp Palace', ['Swamp Palace - Map Chest', 'Swamp Palace - Pot Row Pot Key', + 'Swamp Palace - Trench 1 Pot Key'], ['Swamp Palace (Center)']), + create_dungeon_region(world, player, 'Swamp Palace (Center)', 'Swamp Palace', ['Swamp Palace - Big Chest', 'Swamp Palace - Compass Chest', 'Swamp Palace - Hookshot Pot Key', + 'Swamp Palace - Trench 2 Pot Key'], ['Swamp Palace (North)', 'Swamp Palace (West)']), + create_dungeon_region(world, player, 'Swamp Palace (West)', 'Swamp Palace', ['Swamp Palace - Big Key Chest', 'Swamp Palace - West Chest']), + create_dungeon_region(world, player, 'Swamp Palace (North)', 'Swamp Palace', ['Swamp Palace - Flooded Room - Left', 'Swamp Palace - Flooded Room - Right', + 'Swamp Palace - Waterway Pot Key', 'Swamp Palace - Waterfall Room', + 'Swamp Palace - Boss', 'Swamp Palace - Prize']), + create_dungeon_region(world, player, 'Thieves Town (Entrance)', 'Thieves\' Town', ['Thieves\' Town - Big Key Chest', + 'Thieves\' Town - Map Chest', + 'Thieves\' Town - Compass Chest', + 'Thieves\' Town - Ambush Chest'], ['Thieves Town Big Key Door', 'Thieves Town Exit']), create_dungeon_region(world, player, 'Thieves Town (Deep)', 'Thieves\' Town', ['Thieves\' Town - Attic', - 'Thieves\' Town - Big Chest', - 'Thieves\' Town - Blind\'s Cell'], - ['Blind Fight']), - create_dungeon_region(world, player, 'Blind Fight', 'Thieves\' Town', - ['Thieves\' Town - Boss', 'Thieves\' Town - Prize']), - create_dungeon_region(world, player, 'Skull Woods First Section', 'Skull Woods', ['Skull Woods - Map Chest'], - ['Skull Woods First Section Exit', 'Skull Woods First Section Bomb Jump', - 'Skull Woods First Section South Door', 'Skull Woods First Section West Door']), - create_dungeon_region(world, player, 'Skull Woods First Section (Right)', 'Skull Woods', - ['Skull Woods - Pinball Room'], ['Skull Woods First Section (Right) North Door']), - create_dungeon_region(world, player, 'Skull Woods First Section (Left)', 'Skull Woods', - ['Skull Woods - Compass Chest', 'Skull Woods - Pot Prison'], - ['Skull Woods First Section (Left) Door to Exit', - 'Skull Woods First Section (Left) Door to Right']), - create_dungeon_region(world, player, 'Skull Woods First Section (Top)', 'Skull Woods', - ['Skull Woods - Big Chest'], ['Skull Woods First Section (Top) One-Way Path']), - create_dungeon_region(world, player, 'Skull Woods Second Section (Drop)', 'Skull Woods', None, - ['Skull Woods Second Section (Drop)']), - create_dungeon_region(world, player, 'Skull Woods Second Section', 'Skull Woods', - ['Skull Woods - Big Key Chest'], - ['Skull Woods Second Section Exit (East)', 'Skull Woods Second Section Exit (West)']), - create_dungeon_region(world, player, 'Skull Woods Final Section (Entrance)', 'Skull Woods', - ['Skull Woods - Bridge Room'], - ['Skull Woods Torch Room', 'Skull Woods Final Section Exit']), - create_dungeon_region(world, player, 'Skull Woods Final Section (Mothula)', 'Skull Woods', - ['Skull Woods - Boss', 'Skull Woods - Prize']), - create_dungeon_region(world, player, 'Ice Palace (Entrance)', 'Ice Palace', None, - ['Ice Palace Entrance Room', 'Ice Palace Exit']), - create_dungeon_region(world, player, 'Ice Palace (Main)', 'Ice Palace', - ['Ice Palace - Compass Chest', 'Ice Palace - Freezor Chest', - 'Ice Palace - Big Chest', 'Ice Palace - Iced T Room'], - ['Ice Palace (East)', 'Ice Palace (Kholdstare)']), - create_dungeon_region(world, player, 'Ice Palace (East)', 'Ice Palace', ['Ice Palace - Spike Room'], - ['Ice Palace (East Top)']), - create_dungeon_region(world, player, 'Ice Palace (East Top)', 'Ice Palace', - ['Ice Palace - Big Key Chest', 'Ice Palace - Map Chest']), - create_dungeon_region(world, player, 'Ice Palace (Kholdstare)', 'Ice Palace', - ['Ice Palace - Boss', 'Ice Palace - Prize']), - create_dungeon_region(world, player, 'Misery Mire (Entrance)', 'Misery Mire', None, - ['Misery Mire Entrance Gap', 'Misery Mire Exit']), - create_dungeon_region(world, player, 'Misery Mire (Main)', 'Misery Mire', - ['Misery Mire - Big Chest', 'Misery Mire - Map Chest', 'Misery Mire - Main Lobby', - 'Misery Mire - Bridge Chest', 'Misery Mire - Spike Chest'], - ['Misery Mire (West)', 'Misery Mire Big Key Door']), - create_dungeon_region(world, player, 'Misery Mire (West)', 'Misery Mire', - ['Misery Mire - Compass Chest', 'Misery Mire - Big Key Chest']), - create_dungeon_region(world, player, 'Misery Mire (Final Area)', 'Misery Mire', None, - ['Misery Mire (Vitreous)']), - create_dungeon_region(world, player, 'Misery Mire (Vitreous)', 'Misery Mire', - ['Misery Mire - Boss', 'Misery Mire - Prize']), - create_dungeon_region(world, player, 'Turtle Rock (Entrance)', 'Turtle Rock', None, - ['Turtle Rock Entrance Gap', 'Turtle Rock Exit (Front)']), - create_dungeon_region(world, player, 'Turtle Rock (First Section)', 'Turtle Rock', - ['Turtle Rock - Compass Chest', 'Turtle Rock - Roller Room - Left', - 'Turtle Rock - Roller Room - Right'], - ['Turtle Rock Pokey Room', 'Turtle Rock Entrance Gap Reverse']), - create_dungeon_region(world, player, 'Turtle Rock (Chain Chomp Room)', 'Turtle Rock', - ['Turtle Rock - Chain Chomps'], - ['Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)']), - create_dungeon_region(world, player, 'Turtle Rock (Second Section)', 'Turtle Rock', - ['Turtle Rock - Big Key Chest'], - ['Turtle Rock Ledge Exit (West)', 'Turtle Rock Chain Chomp Staircase', - 'Turtle Rock Big Key Door']), - create_dungeon_region(world, player, 'Turtle Rock (Big Chest)', 'Turtle Rock', ['Turtle Rock - Big Chest'], - ['Turtle Rock (Big Chest) (North)', 'Turtle Rock Ledge Exit (East)']), - create_dungeon_region(world, player, 'Turtle Rock (Crystaroller Room)', 'Turtle Rock', - ['Turtle Rock - Crystaroller Room'], - ['Turtle Rock Dark Room Staircase', 'Turtle Rock Big Key Door Reverse']), - create_dungeon_region(world, player, 'Turtle Rock (Dark Room)', 'Turtle Rock', None, - ['Turtle Rock (Dark Room) (North)', 'Turtle Rock (Dark Room) (South)']), - create_dungeon_region(world, player, 'Turtle Rock (Eye Bridge)', 'Turtle Rock', - ['Turtle Rock - Eye Bridge - Bottom Left', 'Turtle Rock - Eye Bridge - Bottom Right', - 'Turtle Rock - Eye Bridge - Top Left', 'Turtle Rock - Eye Bridge - Top Right'], - ['Turtle Rock Dark Room (South)', 'Turtle Rock (Trinexx)', - 'Turtle Rock Isolated Ledge Exit']), - create_dungeon_region(world, player, 'Turtle Rock (Trinexx)', 'Turtle Rock', - ['Turtle Rock - Boss', 'Turtle Rock - Prize']), - create_dungeon_region(world, player, 'Palace of Darkness (Entrance)', 'Palace of Darkness', - ['Palace of Darkness - Shooter Room'], - ['Palace of Darkness Bridge Room', 'Palace of Darkness Bonk Wall', - 'Palace of Darkness Exit']), - create_dungeon_region(world, player, 'Palace of Darkness (Center)', 'Palace of Darkness', - ['Palace of Darkness - The Arena - Bridge', 'Palace of Darkness - Stalfos Basement'], - ['Palace of Darkness Big Key Chest Staircase', 'Palace of Darkness (North)', - 'Palace of Darkness Big Key Door']), - create_dungeon_region(world, player, 'Palace of Darkness (Big Key Chest)', 'Palace of Darkness', - ['Palace of Darkness - Big Key Chest']), - create_dungeon_region(world, player, 'Palace of Darkness (Bonk Section)', 'Palace of Darkness', - ['Palace of Darkness - The Arena - Ledge', 'Palace of Darkness - Map Chest'], - ['Palace of Darkness Hammer Peg Drop']), - create_dungeon_region(world, player, 'Palace of Darkness (North)', 'Palace of Darkness', - ['Palace of Darkness - Compass Chest', 'Palace of Darkness - Dark Basement - Left', - 'Palace of Darkness - Dark Basement - Right'], + 'Thieves\' Town - Big Chest', + 'Thieves\' Town - Hallway Pot Key', + 'Thieves\' Town - Spike Switch Pot Key', + 'Thieves\' Town - Blind\'s Cell'], ['Blind Fight']), + create_dungeon_region(world, player, 'Blind Fight', 'Thieves\' Town', ['Thieves\' Town - Boss', 'Thieves\' Town - Prize']), + create_dungeon_region(world, player, 'Skull Woods First Section', 'Skull Woods', ['Skull Woods - Map Chest'], ['Skull Woods First Section Exit', 'Skull Woods First Section Bomb Jump', 'Skull Woods First Section South Door', 'Skull Woods First Section West Door']), + create_dungeon_region(world, player, 'Skull Woods First Section (Right)', 'Skull Woods', ['Skull Woods - Pinball Room'], ['Skull Woods First Section (Right) North Door']), + create_dungeon_region(world, player, 'Skull Woods First Section (Left)', 'Skull Woods', ['Skull Woods - Compass Chest', 'Skull Woods - Pot Prison'], ['Skull Woods First Section (Left) Door to Exit', 'Skull Woods First Section (Left) Door to Right']), + create_dungeon_region(world, player, 'Skull Woods First Section (Top)', 'Skull Woods', ['Skull Woods - Big Chest'], ['Skull Woods First Section (Top) One-Way Path']), + create_dungeon_region(world, player, 'Skull Woods Second Section (Drop)', 'Skull Woods', None, ['Skull Woods Second Section (Drop)']), + create_dungeon_region(world, player, 'Skull Woods Second Section', 'Skull Woods', ['Skull Woods - Big Key Chest', 'Skull Woods - West Lobby Pot Key'], ['Skull Woods Second Section Exit (East)', 'Skull Woods Second Section Exit (West)']), + create_dungeon_region(world, player, 'Skull Woods Final Section (Entrance)', 'Skull Woods', ['Skull Woods - Bridge Room'], ['Skull Woods Torch Room', 'Skull Woods Final Section Exit']), + create_dungeon_region(world, player, 'Skull Woods Final Section (Mothula)', 'Skull Woods', ['Skull Woods - Spike Corner Key Drop', 'Skull Woods - Boss', 'Skull Woods - Prize']), + create_dungeon_region(world, player, 'Ice Palace (Entrance)', 'Ice Palace', ['Ice Palace - Jelly Key Drop'], ['Ice Palace (Second Section)', 'Ice Palace Exit']), + create_dungeon_region(world, player, 'Ice Palace (Second Section)', 'Ice Palace', ['Ice Palace - Conveyor Key Drop', 'Ice Palace - Compass Chest'], ['Ice Palace (Main)']), + create_dungeon_region(world, player, 'Ice Palace (Main)', 'Ice Palace', ['Ice Palace - Freezor Chest', + 'Ice Palace - Many Pots Pot Key', + 'Ice Palace - Big Chest', 'Ice Palace - Iced T Room'], ['Ice Palace (East)', 'Ice Palace (Kholdstare)']), + create_dungeon_region(world, player, 'Ice Palace (East)', 'Ice Palace', ['Ice Palace - Spike Room'], ['Ice Palace (East Top)']), + create_dungeon_region(world, player, 'Ice Palace (East Top)', 'Ice Palace', ['Ice Palace - Big Key Chest', 'Ice Palace - Map Chest', 'Ice Palace - Hammer Block Key Drop']), + create_dungeon_region(world, player, 'Ice Palace (Kholdstare)', 'Ice Palace', ['Ice Palace - Boss', 'Ice Palace - Prize']), + create_dungeon_region(world, player, 'Misery Mire (Entrance)', 'Misery Mire', None, ['Misery Mire Entrance Gap', 'Misery Mire Exit']), + create_dungeon_region(world, player, 'Misery Mire (Main)', 'Misery Mire', ['Misery Mire - Big Chest', 'Misery Mire - Map Chest', 'Misery Mire - Main Lobby', + 'Misery Mire - Bridge Chest', 'Misery Mire - Spike Chest', + 'Misery Mire - Spikes Pot Key', 'Misery Mire - Fishbone Pot Key', + 'Misery Mire - Conveyor Crystal Key Drop'], ['Misery Mire (West)', 'Misery Mire Big Key Door']), + create_dungeon_region(world, player, 'Misery Mire (West)', 'Misery Mire', ['Misery Mire - Compass Chest', 'Misery Mire - Big Key Chest']), + create_dungeon_region(world, player, 'Misery Mire (Final Area)', 'Misery Mire', None, ['Misery Mire (Vitreous)']), + create_dungeon_region(world, player, 'Misery Mire (Vitreous)', 'Misery Mire', ['Misery Mire - Boss', 'Misery Mire - Prize']), + create_dungeon_region(world, player, 'Turtle Rock (Entrance)', 'Turtle Rock', None, ['Turtle Rock Entrance Gap', 'Turtle Rock Exit (Front)']), + create_dungeon_region(world, player, 'Turtle Rock (First Section)', 'Turtle Rock', ['Turtle Rock - Compass Chest', 'Turtle Rock - Roller Room - Left', + 'Turtle Rock - Roller Room - Right'], + ['Turtle Rock Entrance to Pokey Room', 'Turtle Rock Entrance Gap Reverse']), + create_dungeon_region(world, player, 'Turtle Rock (Pokey Room)', 'Turtle Rock', ['Turtle Rock - Pokey 1 Key Drop'], ['Turtle Rock (Pokey Room) (North)', 'Turtle Rock (Pokey Room) (South)']), + create_dungeon_region(world, player, 'Turtle Rock (Chain Chomp Room)', 'Turtle Rock', ['Turtle Rock - Chain Chomps'], ['Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)']), + create_dungeon_region(world, player, 'Turtle Rock (Second Section)', 'Turtle Rock', ['Turtle Rock - Big Key Chest', 'Turtle Rock - Pokey 2 Key Drop'], ['Turtle Rock Ledge Exit (West)', 'Turtle Rock Chain Chomp Staircase', 'Turtle Rock Big Key Door']), + create_dungeon_region(world, player, 'Turtle Rock (Big Chest)', 'Turtle Rock', ['Turtle Rock - Big Chest'], ['Turtle Rock (Big Chest) (North)', 'Turtle Rock Ledge Exit (East)']), + create_dungeon_region(world, player, 'Turtle Rock (Crystaroller Room)', 'Turtle Rock', ['Turtle Rock - Crystaroller Room'], ['Turtle Rock Dark Room Staircase', 'Turtle Rock Big Key Door Reverse']), + create_dungeon_region(world, player, 'Turtle Rock (Dark Room)', 'Turtle Rock', None, ['Turtle Rock (Dark Room) (North)', 'Turtle Rock (Dark Room) (South)']), + create_dungeon_region(world, player, 'Turtle Rock (Eye Bridge)', 'Turtle Rock', ['Turtle Rock - Eye Bridge - Bottom Left', 'Turtle Rock - Eye Bridge - Bottom Right', + 'Turtle Rock - Eye Bridge - Top Left', 'Turtle Rock - Eye Bridge - Top Right'], + ['Turtle Rock Dark Room (South)', 'Turtle Rock (Trinexx)', 'Turtle Rock Isolated Ledge Exit']), + create_dungeon_region(world, player, 'Turtle Rock (Trinexx)', 'Turtle Rock', ['Turtle Rock - Boss', 'Turtle Rock - Prize']), + create_dungeon_region(world, player, 'Palace of Darkness (Entrance)', 'Palace of Darkness', ['Palace of Darkness - Shooter Room'], ['Palace of Darkness Bridge Room', 'Palace of Darkness Bonk Wall', 'Palace of Darkness Exit']), + create_dungeon_region(world, player, 'Palace of Darkness (Center)', 'Palace of Darkness', ['Palace of Darkness - The Arena - Bridge', 'Palace of Darkness - Stalfos Basement'], + ['Palace of Darkness Big Key Chest Staircase', 'Palace of Darkness (North)', 'Palace of Darkness Big Key Door']), + create_dungeon_region(world, player, 'Palace of Darkness (Big Key Chest)', 'Palace of Darkness', ['Palace of Darkness - Big Key Chest']), + create_dungeon_region(world, player, 'Palace of Darkness (Bonk Section)', 'Palace of Darkness', ['Palace of Darkness - The Arena - Ledge', 'Palace of Darkness - Map Chest'], ['Palace of Darkness Hammer Peg Drop']), + create_dungeon_region(world, player, 'Palace of Darkness (North)', 'Palace of Darkness', ['Palace of Darkness - Compass Chest', 'Palace of Darkness - Dark Basement - Left', 'Palace of Darkness - Dark Basement - Right'], ['Palace of Darkness Spike Statue Room Door', 'Palace of Darkness Maze Door']), - create_dungeon_region(world, player, 'Palace of Darkness (Maze)', 'Palace of Darkness', - ['Palace of Darkness - Dark Maze - Top', 'Palace of Darkness - Dark Maze - Bottom', - 'Palace of Darkness - Big Chest']), - create_dungeon_region(world, player, 'Palace of Darkness (Harmless Hellway)', 'Palace of Darkness', - ['Palace of Darkness - Harmless Hellway']), - create_dungeon_region(world, player, 'Palace of Darkness (Final Section)', 'Palace of Darkness', - ['Palace of Darkness - Boss', 'Palace of Darkness - Prize']), - create_dungeon_region(world, player, 'Ganons Tower (Entrance)', 'Ganon\'s Tower', - ['Ganons Tower - Bob\'s Torch', 'Ganons Tower - Hope Room - Left', - 'Ganons Tower - Hope Room - Right'], - ['Ganons Tower (Tile Room)', 'Ganons Tower (Hookshot Room)', 'Ganons Tower Big Key Door', - 'Ganons Tower Exit']), - create_dungeon_region(world, player, 'Ganons Tower (Tile Room)', 'Ganon\'s Tower', ['Ganons Tower - Tile Room'], - ['Ganons Tower (Tile Room) Key Door']), - create_dungeon_region(world, player, 'Ganons Tower (Compass Room)', 'Ganon\'s Tower', - ['Ganons Tower - Compass Room - Top Left', 'Ganons Tower - Compass Room - Top Right', - 'Ganons Tower - Compass Room - Bottom Left', - 'Ganons Tower - Compass Room - Bottom Right'], ['Ganons Tower (Bottom) (East)']), - create_dungeon_region(world, player, 'Ganons Tower (Hookshot Room)', 'Ganon\'s Tower', - ['Ganons Tower - DMs Room - Top Left', 'Ganons Tower - DMs Room - Top Right', - 'Ganons Tower - DMs Room - Bottom Left', 'Ganons Tower - DMs Room - Bottom Right'], + create_dungeon_region(world, player, 'Palace of Darkness (Maze)', 'Palace of Darkness', ['Palace of Darkness - Dark Maze - Top', 'Palace of Darkness - Dark Maze - Bottom', 'Palace of Darkness - Big Chest']), + create_dungeon_region(world, player, 'Palace of Darkness (Harmless Hellway)', 'Palace of Darkness', ['Palace of Darkness - Harmless Hellway']), + create_dungeon_region(world, player, 'Palace of Darkness (Final Section)', 'Palace of Darkness', ['Palace of Darkness - Boss', 'Palace of Darkness - Prize']), + create_dungeon_region(world, player, 'Ganons Tower (Entrance)', 'Ganon\'s Tower', ['Ganons Tower - Bob\'s Torch', 'Ganons Tower - Hope Room - Left', + 'Ganons Tower - Hope Room - Right', 'Ganons Tower - Conveyor Cross Pot Key'], + ['Ganons Tower (Tile Room)', 'Ganons Tower (Hookshot Room)', 'Ganons Tower Big Key Door', 'Ganons Tower Exit']), + create_dungeon_region(world, player, 'Ganons Tower (Tile Room)', 'Ganon\'s Tower', ['Ganons Tower - Tile Room'], ['Ganons Tower (Tile Room) Key Door']), + create_dungeon_region(world, player, 'Ganons Tower (Compass Room)', 'Ganon\'s Tower', ['Ganons Tower - Compass Room - Top Left', 'Ganons Tower - Compass Room - Top Right', + 'Ganons Tower - Compass Room - Bottom Left', 'Ganons Tower - Compass Room - Bottom Right', + 'Ganons Tower - Conveyor Star Pits Pot Key'], + ['Ganons Tower (Bottom) (East)']), + create_dungeon_region(world, player, 'Ganons Tower (Hookshot Room)', 'Ganon\'s Tower', ['Ganons Tower - DMs Room - Top Left', 'Ganons Tower - DMs Room - Top Right', + 'Ganons Tower - DMs Room - Bottom Left', 'Ganons Tower - DMs Room - Bottom Right', + 'Ganons Tower - Double Switch Pot Key'], ['Ganons Tower (Map Room)', 'Ganons Tower (Double Switch Room)']), create_dungeon_region(world, player, 'Ganons Tower (Map Room)', 'Ganon\'s Tower', ['Ganons Tower - Map Chest']), - create_dungeon_region(world, player, 'Ganons Tower (Firesnake Room)', 'Ganon\'s Tower', - ['Ganons Tower - Firesnake Room'], ['Ganons Tower (Firesnake Room)']), - create_dungeon_region(world, player, 'Ganons Tower (Teleport Room)', 'Ganon\'s Tower', - ['Ganons Tower - Randomizer Room - Top Left', - 'Ganons Tower - Randomizer Room - Top Right', - 'Ganons Tower - Randomizer Room - Bottom Left', - 'Ganons Tower - Randomizer Room - Bottom Right'], ['Ganons Tower (Bottom) (West)']), - create_dungeon_region(world, player, 'Ganons Tower (Bottom)', 'Ganon\'s Tower', - ['Ganons Tower - Bob\'s Chest', 'Ganons Tower - Big Chest', - 'Ganons Tower - Big Key Room - Left', - 'Ganons Tower - Big Key Room - Right', 'Ganons Tower - Big Key Chest']), - create_dungeon_region(world, player, 'Ganons Tower (Top)', 'Ganon\'s Tower', None, - ['Ganons Tower Torch Rooms']), - create_dungeon_region(world, player, 'Ganons Tower (Before Moldorm)', 'Ganon\'s Tower', - ['Ganons Tower - Mini Helmasaur Room - Left', - 'Ganons Tower - Mini Helmasaur Room - Right', - 'Ganons Tower - Pre-Moldorm Chest'], ['Ganons Tower Moldorm Door']), - create_dungeon_region(world, player, 'Ganons Tower (Moldorm)', 'Ganon\'s Tower', None, - ['Ganons Tower Moldorm Gap']), - create_dungeon_region(world, player, 'Agahnim 2', 'Ganon\'s Tower', - ['Ganons Tower - Validation Chest', 'Agahnim 2'], None), + create_dungeon_region(world, player, 'Ganons Tower (Firesnake Room)', 'Ganon\'s Tower', ['Ganons Tower - Firesnake Room'], ['Ganons Tower (Firesnake Room)']), + create_dungeon_region(world, player, 'Ganons Tower (Teleport Room)', 'Ganon\'s Tower', ['Ganons Tower - Randomizer Room - Top Left', 'Ganons Tower - Randomizer Room - Top Right', + 'Ganons Tower - Randomizer Room - Bottom Left', 'Ganons Tower - Randomizer Room - Bottom Right'], + ['Ganons Tower (Bottom) (West)']), + create_dungeon_region(world, player, 'Ganons Tower (Bottom)', 'Ganon\'s Tower', ['Ganons Tower - Bob\'s Chest', 'Ganons Tower - Big Chest', 'Ganons Tower - Big Key Room - Left', + 'Ganons Tower - Big Key Room - Right', 'Ganons Tower - Big Key Chest']), + create_dungeon_region(world, player, 'Ganons Tower (Top)', 'Ganon\'s Tower', None, ['Ganons Tower Torch Rooms']), + create_dungeon_region(world, player, 'Ganons Tower (Before Moldorm)', 'Ganon\'s Tower', ['Ganons Tower - Mini Helmasaur Room - Left', 'Ganons Tower - Mini Helmasaur Room - Right', + 'Ganons Tower - Pre-Moldorm Chest', 'Ganons Tower - Mini Helmasaur Key Drop'], ['Ganons Tower Moldorm Door']), + create_dungeon_region(world, player, 'Ganons Tower (Moldorm)', 'Ganon\'s Tower', None, ['Ganons Tower Moldorm Gap']), + create_dungeon_region(world, player, 'Agahnim 2', 'Ganon\'s Tower', ['Ganons Tower - Validation Chest', 'Agahnim 2'], None), create_cave_region(world, player, 'Pyramid', 'a drop\'s exit', ['Ganon'], ['Ganon Drop']), create_cave_region(world, player, 'Bottom of Pyramid', 'a drop\'s exit', None, ['Pyramid Exit']), create_dw_region(world, player, 'Pyramid Ledge', None, ['Pyramid Entrance', 'Pyramid Drop']), @@ -533,8 +410,12 @@ def _create_region(world: MultiWorld, player: int, name: str, type: LTTPRegionTy ret.exits.append(Entrance(player, exit, ret)) if locations: for location in locations: - address, player_address, crystal, hint_text = location_table[location] - ret.locations.append(ALttPLocation(player, location, address, crystal, hint_text, ret, player_address)) + if location in key_drop_data: + ko_hint = key_drop_data[location][2] + ret.locations.append(ALttPLocation(player, location, key_drop_data[location][1], False, ko_hint, ret, key_drop_data[location][0])) + else: + address, player_address, crystal, hint_text = location_table[location] + ret.locations.append(ALttPLocation(player, location, address, crystal, hint_text, ret, player_address)) return ret @@ -587,39 +468,39 @@ def mark_light_world_regions(world, player: int): key_drop_data = { - 'Hyrule Castle - Map Guard Key Drop': [0x140036, 0x140037], - 'Hyrule Castle - Boomerang Guard Key Drop': [0x140033, 0x140034], - 'Hyrule Castle - Key Rat Key Drop': [0x14000c, 0x14000d], - 'Hyrule Castle - Big Key Drop': [0x14003c, 0x14003d], - 'Eastern Palace - Dark Square Pot Key': [0x14005a, 0x14005b], - 'Eastern Palace - Dark Eyegore Key Drop': [0x140048, 0x140049], - 'Desert Palace - Desert Tiles 1 Pot Key': [0x140030, 0x140031], - 'Desert Palace - Beamos Hall Pot Key': [0x14002a, 0x14002b], - 'Desert Palace - Desert Tiles 2 Pot Key': [0x140027, 0x140028], - 'Castle Tower - Dark Archer Key Drop': [0x140060, 0x140061], - 'Castle Tower - Circle of Pots Key Drop': [0x140051, 0x140052], - 'Swamp Palace - Pot Row Pot Key': [0x140018, 0x140019], - 'Swamp Palace - Trench 1 Pot Key': [0x140015, 0x140016], - 'Swamp Palace - Hookshot Pot Key': [0x140012, 0x140013], - 'Swamp Palace - Trench 2 Pot Key': [0x14000f, 0x140010], - 'Swamp Palace - Waterway Pot Key': [0x140009, 0x14000a], - 'Skull Woods - West Lobby Pot Key': [0x14002d, 0x14002e], - 'Skull Woods - Spike Corner Key Drop': [0x14001b, 0x14001c], - 'Thieves\' Town - Hallway Pot Key': [0x14005d, 0x14005e], - 'Thieves\' Town - Spike Switch Pot Key': [0x14004e, 0x14004f], - 'Ice Palace - Jelly Key Drop': [0x140003, 0x140004], - 'Ice Palace - Conveyor Key Drop': [0x140021, 0x140022], - 'Ice Palace - Hammer Block Key Drop': [0x140024, 0x140025], - 'Ice Palace - Many Pots Pot Key': [0x140045, 0x140046], - 'Misery Mire - Spikes Pot Key': [0x140054, 0x140055], - 'Misery Mire - Fishbone Pot Key': [0x14004b, 0x14004c], - 'Misery Mire - Conveyor Crystal Key Drop': [0x140063, 0x140064], - 'Turtle Rock - Pokey 1 Key Drop': [0x140057, 0x140058], - 'Turtle Rock - Pokey 2 Key Drop': [0x140006, 0x140007], - 'Ganons Tower - Conveyor Cross Pot Key': [0x14003f, 0x140040], - 'Ganons Tower - Double Switch Pot Key': [0x140042, 0x140043], - 'Ganons Tower - Conveyor Star Pits Pot Key': [0x140039, 0x14003a], - 'Ganons Tower - Mini Helmasaur Key Drop': [0x14001e, 0x14001f] + 'Hyrule Castle - Map Guard Key Drop': [0x140036, 0x140037, 'in Hyrule Castle', 'Small Key (Hyrule Castle)'], + 'Hyrule Castle - Boomerang Guard Key Drop': [0x140033, 0x140034, 'in Hyrule Castle', 'Small Key (Hyrule Castle)'], + 'Sewers - Key Rat Key Drop': [0x14000c, 0x14000d, 'in the sewers', 'Small Key (Hyrule Castle)'], + 'Hyrule Castle - Big Key Drop': [0x14003c, 0x14003d, 'in Hyrule Castle', 'Big Key (Hyrule Castle)'], + 'Eastern Palace - Dark Square Pot Key': [0x14005a, 0x14005b, 'in Eastern Palace', 'Small Key (Eastern Palace)'], + 'Eastern Palace - Dark Eyegore Key Drop': [0x140048, 0x140049, 'in Eastern Palace', 'Small Key (Eastern Palace)'], + 'Desert Palace - Desert Tiles 1 Pot Key': [0x140030, 0x140031, 'in Desert Palace', 'Small Key (Desert Palace)'], + 'Desert Palace - Beamos Hall Pot Key': [0x14002a, 0x14002b, 'in Desert Palace', 'Small Key (Desert Palace)'], + 'Desert Palace - Desert Tiles 2 Pot Key': [0x140027, 0x140028, 'in Desert Palace', 'Small Key (Desert Palace)'], + 'Castle Tower - Dark Archer Key Drop': [0x140060, 0x140061, 'in Castle Tower', 'Small Key (Agahnims Tower)'], + 'Castle Tower - Circle of Pots Key Drop': [0x140051, 0x140052, 'in Castle Tower', 'Small Key (Agahnims Tower)'], + 'Swamp Palace - Pot Row Pot Key': [0x140018, 0x140019, 'in Swamp Palace', 'Small Key (Swamp Palace)'], + 'Swamp Palace - Trench 1 Pot Key': [0x140015, 0x140016, 'in Swamp Palace', 'Small Key (Swamp Palace)'], + 'Swamp Palace - Hookshot Pot Key': [0x140012, 0x140013, 'in Swamp Palace', 'Small Key (Swamp Palace)'], + 'Swamp Palace - Trench 2 Pot Key': [0x14000f, 0x140010, 'in Swamp Palace', 'Small Key (Swamp Palace)'], + 'Swamp Palace - Waterway Pot Key': [0x140009, 0x14000a, 'in Swamp Palace', 'Small Key (Swamp Palace)'], + 'Skull Woods - West Lobby Pot Key': [0x14002d, 0x14002e, 'in Skull Woods', 'Small Key (Skull Woods)'], + 'Skull Woods - Spike Corner Key Drop': [0x14001b, 0x14001c, 'near Mothula', 'Small Key (Skull Woods)'], + "Thieves' Town - Hallway Pot Key": [0x14005d, 0x14005e, "in Thieves' Town", 'Small Key (Thieves Town)'], + "Thieves' Town - Spike Switch Pot Key": [0x14004e, 0x14004f, "in Thieves' Town", 'Small Key (Thieves Town)'], + 'Ice Palace - Jelly Key Drop': [0x140003, 0x140004, 'in Ice Palace', 'Small Key (Ice Palace)'], + 'Ice Palace - Conveyor Key Drop': [0x140021, 0x140022, 'in Ice Palace', 'Small Key (Ice Palace)'], + 'Ice Palace - Hammer Block Key Drop': [0x140024, 0x140025, 'in Ice Palace', 'Small Key (Ice Palace)'], + 'Ice Palace - Many Pots Pot Key': [0x140045, 0x140046, 'in Ice Palace', 'Small Key (Ice Palace)'], + 'Misery Mire - Spikes Pot Key': [0x140054, 0x140055 , 'in Misery Mire', 'Small Key (Misery Mire)'], + 'Misery Mire - Fishbone Pot Key': [0x14004b, 0x14004c, 'in forgotten Mire', 'Small Key (Misery Mire)'], + 'Misery Mire - Conveyor Crystal Key Drop': [0x140063, 0x140064 , 'in Misery Mire', 'Small Key (Misery Mire)'], + 'Turtle Rock - Pokey 1 Key Drop': [0x140057, 0x140058, 'in Turtle Rock', 'Small Key (Turtle Rock)'], + 'Turtle Rock - Pokey 2 Key Drop': [0x140006, 0x140007, 'in Turtle Rock', 'Small Key (Turtle Rock)'], + 'Ganons Tower - Conveyor Cross Pot Key': [0x14003f, 0x140040, "in Ganon's Tower", 'Small Key (Ganons Tower)'], + 'Ganons Tower - Double Switch Pot Key': [0x140042, 0x140043, "in Ganon's Tower", 'Small Key (Ganons Tower)'], + 'Ganons Tower - Conveyor Star Pits Pot Key': [0x140039, 0x14003a, "in Ganon's Tower", 'Small Key (Ganons Tower)'], + 'Ganons Tower - Mini Helmasaur Key Drop': [0x14001e, 0x14001f, "atop Ganon's Tower", 'Small Key (Ganons Tower)'] } # tuple contents: diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index ed222b5f5d14..47cea8c20ea4 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -25,7 +25,7 @@ from .Shops import ShopType, ShopPriceType from .Dungeons import dungeon_music_addresses -from .Regions import old_location_address_to_new_location_address +from .Regions import old_location_address_to_new_location_address, key_drop_data from .Text import MultiByteTextMapper, text_addresses, Credits, TextTable from .Text import Uncle_texts, Ganon1_texts, TavernMan_texts, Sahasrahla2_texts, Triforce_texts, \ Blind_texts, \ @@ -428,6 +428,18 @@ def patch_enemizer(world, rom: LocalRom, enemizercli, output_directory): rom.write_byte(0x04DE81, 6) rom.write_byte(0x1B0101, 0) # Do not close boss room door on entry. + # Moblins attached to "key drop" locations crash the game when dropping their item when Key Drop Shuffle is on. + # Replace them with a Slime enemy if they are placed. + if multiworld.key_drop_shuffle[player]: + key_drop_enemies = { + 0x4DA20, 0x4DA5C, 0x4DB7F, 0x4DD73, 0x4DDC3, 0x4DE07, 0x4E201, + 0x4E20A, 0x4E326, 0x4E4F7, 0x4E686, 0x4E70C, 0x4E7C8, 0x4E7FA + } + for enemy in key_drop_enemies: + if rom.read_byte(enemy) == 0x12: + logging.debug(f"Moblin found and replaced at {enemy} in world {player}") + rom.write_byte(enemy, 0x8F) + for used in (randopatch_path, options_path): try: os.remove(used) @@ -897,6 +909,29 @@ def credits_digit(num): credits_total += 30 if 'w' in world.shop_shuffle[player] else 27 rom.write_byte(0x187010, credits_total) # dynamic credits + + if world.key_drop_shuffle[player]: + rom.write_byte(0x140000, 1) # enable key drop shuffle + credits_total += len(key_drop_data) + # update dungeon counters + rom.write_byte(0x187001, 12) # Hyrule Castle + rom.write_byte(0x187002, 8) # Eastern Palace + rom.write_byte(0x187003, 9) # Desert Palace + rom.write_byte(0x187004, 4) # Agahnims Tower + rom.write_byte(0x187005, 15) # Swamp Palace + rom.write_byte(0x187007, 11) # Misery Mire + rom.write_byte(0x187008, 10) # Skull Woods + rom.write_byte(0x187009, 12) # Ice Palace + rom.write_byte(0x18700B, 10) # Thieves Town + rom.write_byte(0x18700C, 14) # Turtle Rock + rom.write_byte(0x18700D, 31) # Ganons Tower + # update credits GT Big Key counter + gt_bigkey_top, gt_bigkey_bottom = credits_digit(5) + rom.write_byte(0x118B6A, gt_bigkey_top) + rom.write_byte(0x118B88, gt_bigkey_bottom) + + + # collection rate address: 238C37 first_top, first_bot = credits_digit((credits_total / 100) % 10) mid_top, mid_bot = credits_digit((credits_total / 10) % 10) @@ -1824,10 +1859,10 @@ def apply_oof_sfx(rom, oof: str): # (We need to insert the second sigil at the end) rom.write_bytes(0x12803A, oof_bytes) rom.write_bytes(0x12803A + len(oof_bytes), [0xEB, 0xEB]) - + #Enemizer patch: prevent Enemizer from overwriting $3188 in SPC memory with an unused sound effect ("WHAT") rom.write_bytes(0x13000D, [0x00, 0x00, 0x00, 0x08]) - + def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, sprite: str, oof: str, palettes_options, world=None, player=1, allow_random_on_event=False, reduceflashing=False, diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index ce4a941ead1b..1fddecd8f4f4 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -231,26 +231,41 @@ def global_rules(world, player): set_rule(world.get_location('Hookshot Cave - Bottom Left', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('Sewers Door', player), - lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player) or ( + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4) or ( world.smallkey_shuffle[player] == smallkey_shuffle.option_universal and world.mode[ player] == 'standard')) # standard universal small keys cannot access the shop set_rule(world.get_entrance('Sewers Back Door', player), - lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player)) + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4)) set_rule(world.get_entrance('Agahnim 1', player), - lambda state: has_sword(state, player) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 2)) + lambda state: has_sword(state, player) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 4)) set_rule(world.get_location('Castle Tower - Room 03', player), lambda state: can_kill_most_things(state, player, 8)) set_rule(world.get_location('Castle Tower - Dark Maze', player), lambda state: can_kill_most_things(state, player, 8) and state._lttp_has_key('Small Key (Agahnims Tower)', player)) - + set_rule(world.get_location('Castle Tower - Dark Archer Key Drop', player), + lambda state: can_kill_most_things(state, player, 8) and state._lttp_has_key('Small Key (Agahnims Tower)', + player, 2)) + set_rule(world.get_location('Castle Tower - Circle of Pots Key Drop', player), + lambda state: can_kill_most_things(state, player, 8) and state._lttp_has_key('Small Key (Agahnims Tower)', + player, 3)) + set_always_allow(world.get_location('Eastern Palace - Big Key Chest', player), + lambda state, item: item.name == 'Big Key (Eastern Palace)' and item.player == player) + set_rule(world.get_location('Eastern Palace - Big Key Chest', player), + lambda state: state._lttp_has_key('Small Key (Eastern Palace)', player, 2) or + ((location_item_name(state, 'Eastern Palace - Big Key Chest', player) == ('Big Key (Eastern Palace)', player) + and state.has('Small Key (Eastern Palace)', player)))) + set_rule(world.get_location('Eastern Palace - Dark Eyegore Key Drop', player), + lambda state: state.has('Big Key (Eastern Palace)', player)) set_rule(world.get_location('Eastern Palace - Big Chest', player), lambda state: state.has('Big Key (Eastern Palace)', player)) ep_boss = world.get_location('Eastern Palace - Boss', player) set_rule(ep_boss, lambda state: state.has('Big Key (Eastern Palace)', player) and + state._lttp_has_key('Small Key (Eastern Palace)', player, 2) and ep_boss.parent_region.dungeon.boss.can_defeat(state)) ep_prize = world.get_location('Eastern Palace - Prize', player) set_rule(ep_prize, lambda state: state.has('Big Key (Eastern Palace)', player) and + state._lttp_has_key('Small Key (Eastern Palace)', player, 2) and ep_prize.parent_region.dungeon.boss.can_defeat(state)) if not world.enemy_shuffle[player]: add_rule(ep_boss, lambda state: can_shoot_arrows(state, player)) @@ -258,9 +273,13 @@ def global_rules(world, player): set_rule(world.get_location('Desert Palace - Big Chest', player), lambda state: state.has('Big Key (Desert Palace)', player)) set_rule(world.get_location('Desert Palace - Torch', player), lambda state: state.has('Pegasus Boots', player)) - set_rule(world.get_entrance('Desert Palace East Wing', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player)) - set_rule(world.get_location('Desert Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player) and state.has('Big Key (Desert Palace)', player) and has_fire_source(state, player) and state.multiworld.get_location('Desert Palace - Prize', player).parent_region.dungeon.boss.can_defeat(state)) - set_rule(world.get_location('Desert Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player) and state.has('Big Key (Desert Palace)', player) and has_fire_source(state, player) and state.multiworld.get_location('Desert Palace - Boss', player).parent_region.dungeon.boss.can_defeat(state)) + + set_rule(world.get_entrance('Desert Palace East Wing', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player, 4)) + set_rule(world.get_location('Desert Palace - Big Key Chest', player), lambda state: can_kill_most_things(state, player)) + set_rule(world.get_location('Desert Palace - Beamos Hall Pot Key', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player, 2) and can_kill_most_things(state, player)) + set_rule(world.get_location('Desert Palace - Desert Tiles 2 Pot Key', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player, 3) and can_kill_most_things(state, player)) + set_rule(world.get_location('Desert Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player, 4) and state.has('Big Key (Desert Palace)', player) and has_fire_source(state, player) and state.multiworld.get_location('Desert Palace - Prize', player).parent_region.dungeon.boss.can_defeat(state)) + set_rule(world.get_location('Desert Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Desert Palace)', player, 4) and state.has('Big Key (Desert Palace)', player) and has_fire_source(state, player) and state.multiworld.get_location('Desert Palace - Boss', player).parent_region.dungeon.boss.can_defeat(state)) # logic patch to prevent placing a crystal in Desert that's required to reach the required keys if not (world.smallkey_shuffle[player] and world.bigkey_shuffle[player]): @@ -275,57 +294,98 @@ def global_rules(world, player): set_rule(world.get_entrance('Swamp Palace Moat', player), lambda state: state.has('Flippers', player) and state.has('Open Floodgate', player)) set_rule(world.get_entrance('Swamp Palace Small Key Door', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player)) - set_rule(world.get_entrance('Swamp Palace (Center)', player), lambda state: state.has('Hammer', player)) + set_rule(world.get_location('Swamp Palace - Trench 1 Pot Key', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 2)) + set_rule(world.get_entrance('Swamp Palace (Center)', player), lambda state: state.has('Hammer', player) and state._lttp_has_key('Small Key (Swamp Palace)', player, 3)) + set_rule(world.get_location('Swamp Palace - Hookshot Pot Key', player), lambda state: state.has('Hookshot', player)) + set_rule(world.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6) + if state.has('Hookshot', player) + else state._lttp_has_key('Small Key (Swamp Palace)', player, 4)) set_rule(world.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player)) if world.accessibility[player] != 'locations': allow_self_locking_items(world.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)') - set_rule(world.get_entrance('Swamp Palace (North)', player), lambda state: state.has('Hookshot', player)) + set_rule(world.get_entrance('Swamp Palace (North)', player), lambda state: state.has('Hookshot', player) and state._lttp_has_key('Small Key (Swamp Palace)', player, 5)) if not world.smallkey_shuffle[player] and world.logic[player] not in ['hybridglitches', 'nologic']: forbid_item(world.get_location('Swamp Palace - Entrance', player), 'Big Key (Swamp Palace)', player) + set_rule(world.get_location('Swamp Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6)) + set_rule(world.get_location('Swamp Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6)) set_rule(world.get_entrance('Thieves Town Big Key Door', player), lambda state: state.has('Big Key (Thieves Town)', player)) - set_rule(world.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player)) - set_rule(world.get_location('Thieves\' Town - Big Chest', player), lambda state: (state._lttp_has_key('Small Key (Thieves Town)', player)) and state.has('Hammer', player)) + + if world.worlds[player].dungeons["Thieves Town"].boss.enemizer_name == "Blind": + set_rule(world.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3)) + + set_rule(world.get_location('Thieves\' Town - Big Chest', player), + lambda state: (state._lttp_has_key('Small Key (Thieves Town)', player, 3)) and state.has('Hammer', player)) if world.accessibility[player] != 'locations': allow_self_locking_items(world.get_location('Thieves\' Town - Big Chest', player), 'Small Key (Thieves Town)') - set_rule(world.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player)) - set_rule(world.get_entrance('Skull Woods First Section South Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player)) - set_rule(world.get_entrance('Skull Woods First Section (Right) North Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player)) - set_rule(world.get_entrance('Skull Woods First Section West Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 2)) # ideally would only be one key, but we may have spent thst key already on escaping the right section - set_rule(world.get_entrance('Skull Woods First Section (Left) Door to Exit', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 2)) + set_rule(world.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3)) + set_rule(world.get_location('Thieves\' Town - Spike Switch Pot Key', player), + lambda state: state._lttp_has_key('Small Key (Thieves Town)', player)) + + # We need so many keys in the SW doors because they are all reachable as the last door (except for the door to mothula) + set_rule(world.get_entrance('Skull Woods First Section South Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5)) + set_rule(world.get_entrance('Skull Woods First Section (Right) North Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5)) + set_rule(world.get_entrance('Skull Woods First Section West Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5)) + set_rule(world.get_entrance('Skull Woods First Section (Left) Door to Exit', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5)) set_rule(world.get_location('Skull Woods - Big Chest', player), lambda state: state.has('Big Key (Skull Woods)', player)) if world.accessibility[player] != 'locations': allow_self_locking_items(world.get_location('Skull Woods - Big Chest', player), 'Big Key (Skull Woods)') - set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 3) and state.has('Fire Rod', player) and has_sword(state, player)) # sword required for curtain + set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 4) and state.has('Fire Rod', player) and has_sword(state, player)) # sword required for curtain + add_rule(world.get_location('Skull Woods - Prize', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5)) + add_rule(world.get_location('Skull Woods - Boss', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5)) - set_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: can_melt_things(state, player)) + set_rule(world.get_location('Ice Palace - Jelly Key Drop', player), lambda state: can_melt_things(state, player)) + set_rule(world.get_entrance('Ice Palace (Second Section)', player), lambda state: can_melt_things(state, player) and state._lttp_has_key('Small Key (Ice Palace)', player)) + set_rule(world.get_entrance('Ice Palace (Main)', player), lambda state: state._lttp_has_key('Small Key (Ice Palace)', player, 2)) set_rule(world.get_location('Ice Palace - Big Chest', player), lambda state: state.has('Big Key (Ice Palace)', player)) - set_rule(world.get_entrance('Ice Palace (Kholdstare)', player), lambda state: can_lift_rocks(state, player) and state.has('Hammer', player) and state.has('Big Key (Ice Palace)', player) and (state._lttp_has_key('Small Key (Ice Palace)', player, 2) or (state.has('Cane of Somaria', player) and state._lttp_has_key('Small Key (Ice Palace)', player, 1)))) - set_rule(world.get_entrance('Ice Palace (East)', player), lambda state: (state.has('Hookshot', player) or ( - item_name_in_location_names(state, 'Big Key (Ice Palace)', player, [('Ice Palace - Spike Room', player), ('Ice Palace - Big Key Chest', player), ('Ice Palace - Map Chest', player)]) and state._lttp_has_key('Small Key (Ice Palace)', player))) and (state.multiworld.can_take_damage[player] or state.has('Hookshot', player) or state.has('Cape', player) or state.has('Cane of Byrna', player))) + set_rule(world.get_entrance('Ice Palace (Kholdstare)', player), lambda state: can_lift_rocks(state, player) and state.has('Hammer', player) and state.has('Big Key (Ice Palace)', player) and (state._lttp_has_key('Small Key (Ice Palace)', player, 6) or (state.has('Cane of Somaria', player) and state._lttp_has_key('Small Key (Ice Palace)', player, 5)))) + # This is a complicated rule, so let's break it down. + # Hookshot always suffices to get to the right side. + # Also, once you get over there, you have to cross the spikes, so that's the last line. + # Alternatively, we could not have hookshot. Then we open the keydoor into right side in order to get there. + # This is conditional on whether we have the big key or not, as big key opens the ability to waste more keys. + # Specifically, if we have big key we can burn 2 extra keys near the boss and will need +2 keys. That's all of them as this could be the last door. + # Hence if big key is available then it's 6 keys, otherwise 4 keys. + # If key_drop is off, then we have 3 drop keys available, and can never satisfy the 6 key requirement because one key is on right side, + # so this reduces perfectly to original logic. + set_rule(world.get_entrance('Ice Palace (East)', player), lambda state: (state.has('Hookshot', player) or + (state._lttp_has_key('Small Key (Ice Palace)', player, 4) + if item_name_in_location_names(state, 'Big Key (Ice Palace)', player, [('Ice Palace - Spike Room', player), + ('Ice Palace - Hammer Block Key Drop', player), + ('Ice Palace - Big Key Chest', player), + ('Ice Palace - Map Chest', player)]) + else state._lttp_has_key('Small Key (Ice Palace)', player, 6))) and + (state.multiworld.can_take_damage[player] or state.has('Hookshot', player) or state.has('Cape', player) or state.has('Cane of Byrna', player))) set_rule(world.get_entrance('Ice Palace (East Top)', player), lambda state: can_lift_rocks(state, player) and state.has('Hammer', player)) set_rule(world.get_entrance('Misery Mire Entrance Gap', player), lambda state: (state.has('Pegasus Boots', player) or state.has('Hookshot', player)) and (has_sword(state, player) or state.has('Fire Rod', player) or state.has('Ice Rod', player) or state.has('Hammer', player) or state.has('Cane of Somaria', player) or can_shoot_arrows(state, player))) # need to defeat wizzrobes, bombs don't work ... + set_rule(world.get_location('Misery Mire - Fishbone Pot Key', player), lambda state: state.has('Big Key (Misery Mire)', player) or state._lttp_has_key('Small Key (Misery Mire)', player, 4)) + set_rule(world.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player)) set_rule(world.get_location('Misery Mire - Spike Chest', player), lambda state: (state.multiworld.can_take_damage[player] and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player)) set_rule(world.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('Big Key (Misery Mire)', player)) - # you can squander the free small key from the pot by opening the south door to the north west switch room, locking you out of accessing a color switch ... - # big key gives backdoor access to that from the teleporter in the north west - set_rule(world.get_location('Misery Mire - Map Chest', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 1) or state.has('Big Key (Misery Mire)', player)) - set_rule(world.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 1) or state._lttp_has_key('Big Key (Misery Mire)', player)) + # How to access crystal switch: + # If have big key: then you will need 2 small keys to be able to hit switch and return to main area, as you can burn key in dark room + # If not big key: cannot burn key in dark room, hence need only 1 key. all doors immediately available lead to a crystal switch. + # The listed chests are those which can be reached if you can reach a crystal switch. + set_rule(world.get_location('Misery Mire - Map Chest', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2)) + set_rule(world.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2)) # we can place a small key in the West wing iff it also contains/blocks the Big Key, as we cannot reach and softlock with the basement key door yet - set_rule(world.get_entrance('Misery Mire (West)', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2) if (( - location_item_name(state, 'Misery Mire - Compass Chest', player) in [('Big Key (Misery Mire)', player)]) or - ( - location_item_name(state, 'Misery Mire - Big Key Chest', player) in [('Big Key (Misery Mire)', player)])) else state._lttp_has_key('Small Key (Misery Mire)', player, 3)) + set_rule(world.get_location('Misery Mire - Conveyor Crystal Key Drop', player), + lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 4) + if location_item_name(state, 'Misery Mire - Compass Chest', player) == ('Big Key (Misery Mire)', player) or location_item_name(state, 'Misery Mire - Big Key Chest', player) == ('Big Key (Misery Mire)', player) or location_item_name(state, 'Misery Mire - Conveyor Crystal Key Drop', player) == ('Big Key (Misery Mire)', player) + else state._lttp_has_key('Small Key (Misery Mire)', player, 5)) + set_rule(world.get_entrance('Misery Mire (West)', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 5) + if ((location_item_name(state, 'Misery Mire - Compass Chest', player) in [('Big Key (Misery Mire)', player)]) or (location_item_name(state, 'Misery Mire - Big Key Chest', player) in [('Big Key (Misery Mire)', player)])) + else state._lttp_has_key('Small Key (Misery Mire)', player, 6)) set_rule(world.get_location('Misery Mire - Compass Chest', player), lambda state: has_fire_source(state, player)) set_rule(world.get_location('Misery Mire - Big Key Chest', player), lambda state: has_fire_source(state, player)) set_rule(world.get_entrance('Misery Mire (Vitreous)', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('Turtle Rock Entrance Gap', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('Turtle Rock Entrance Gap Reverse', player), lambda state: state.has('Cane of Somaria', player)) - set_rule(world.get_location('Turtle Rock - Compass Chest', player), lambda state: state.has('Cane of Somaria', player)) # We could get here from the middle section without Cane as we don't cross the entrance gap! + set_rule(world.get_location('Turtle Rock - Compass Chest', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_location('Turtle Rock - Roller Room - Left', player), lambda state: state.has('Cane of Somaria', player) and state.has('Fire Rod', player)) set_rule(world.get_location('Turtle Rock - Roller Room - Right', player), lambda state: state.has('Cane of Somaria', player) and state.has('Fire Rod', player)) set_rule(world.get_location('Turtle Rock - Big Chest', player), lambda state: state.has('Big Key (Turtle Rock)', player) and (state.has('Cane of Somaria', player) or state.has('Hookshot', player))) @@ -337,7 +397,7 @@ def global_rules(world, player): set_rule(world.get_location('Turtle Rock - Eye Bridge - Bottom Right', player), lambda state: state.has('Cane of Byrna', player) or state.has('Cape', player) or state.has('Mirror Shield', player)) set_rule(world.get_location('Turtle Rock - Eye Bridge - Top Left', player), lambda state: state.has('Cane of Byrna', player) or state.has('Cape', player) or state.has('Mirror Shield', player)) set_rule(world.get_location('Turtle Rock - Eye Bridge - Top Right', player), lambda state: state.has('Cane of Byrna', player) or state.has('Cape', player) or state.has('Mirror Shield', player)) - set_rule(world.get_entrance('Turtle Rock (Trinexx)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4) and state.has('Big Key (Turtle Rock)', player) and state.has('Cane of Somaria', player)) + set_rule(world.get_entrance('Turtle Rock (Trinexx)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6) and state.has('Big Key (Turtle Rock)', player) and state.has('Cane of Somaria', player)) if not world.enemy_shuffle[player]: set_rule(world.get_entrance('Palace of Darkness Bonk Wall', player), lambda state: can_shoot_arrows(state, player)) @@ -361,35 +421,46 @@ def global_rules(world, player): # these key rules are conservative, you might be able to get away with more lenient rules randomizer_room_chests = ['Ganons Tower - Randomizer Room - Top Left', 'Ganons Tower - Randomizer Room - Top Right', 'Ganons Tower - Randomizer Room - Bottom Left', 'Ganons Tower - Randomizer Room - Bottom Right'] - compass_room_chests = ['Ganons Tower - Compass Room - Top Left', 'Ganons Tower - Compass Room - Top Right', 'Ganons Tower - Compass Room - Bottom Left', 'Ganons Tower - Compass Room - Bottom Right'] + compass_room_chests = ['Ganons Tower - Compass Room - Top Left', 'Ganons Tower - Compass Room - Top Right', 'Ganons Tower - Compass Room - Bottom Left', 'Ganons Tower - Compass Room - Bottom Right', 'Ganons Tower - Conveyor Star Pits Pot Key'] + back_chests = ['Ganons Tower - Bob\'s Chest', 'Ganons Tower - Big Chest', 'Ganons Tower - Big Key Room - Left', 'Ganons Tower - Big Key Room - Right', 'Ganons Tower - Big Key Chest'] + set_rule(world.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player)) set_rule(world.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player))) - set_rule(world.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 4) or ( - location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player), ('Small Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 3))) - if world.accessibility[player] != 'locations': - set_always_allow(world.get_location('Ganons Tower - Map Chest', player), lambda state, item: item.name == 'Small Key (Ganons Tower)' and item.player == player and state._lttp_has_key('Small Key (Ganons Tower)', player, 3) and state.can_reach('Ganons Tower (Hookshot Room)', 'region', player)) - - # It is possible to need more than 2 keys to get through this entrance if you spend keys elsewhere. We reflect this in the chest requirements. - # However we need to leave these at the lower values to derive that with 3 keys it is always possible to reach Bob and Ice Armos. - set_rule(world.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 2)) - # It is possible to need more than 3 keys .... - set_rule(world.get_entrance('Ganons Tower (Firesnake Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 3)) - - #The actual requirements for these rooms to avoid key-lock - set_rule(world.get_location('Ganons Tower - Firesnake Room', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 3) or (( - item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) or item_name_in_location_names(state, 'Small Key (Ganons Tower)', player, [('Ganons Tower - Firesnake Room', player)])) and state._lttp_has_key('Small Key (Ganons Tower)', player, 2))) + if world.pot_shuffle[player]: + # Pot Shuffle can move this check into the hookshot room + set_rule(world.get_location('Ganons Tower - Conveyor Cross Pot Key', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player))) + set_rule(world.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or ( + location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 6))) + + # this seemed to be causing generation failure, disable for now + # if world.accessibility[player] != 'locations': + # set_always_allow(world.get_location('Ganons Tower - Map Chest', player), lambda state, item: item.name == 'Small Key (Ganons Tower)' and item.player == player and state._lttp_has_key('Small Key (Ganons Tower)', player, 7) and state.can_reach('Ganons Tower (Hookshot Room)', 'region', player)) + + # It is possible to need more than 6 keys to get through this entrance if you spend keys elsewhere. We reflect this in the chest requirements. + # However we need to leave these at the lower values to derive that with 7 keys it is always possible to reach Bob and Ice Armos. + set_rule(world.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 6)) + # It is possible to need more than 7 keys .... + set_rule(world.get_entrance('Ganons Tower (Firesnake Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or ( + item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests + back_chests, [player] * len(randomizer_room_chests + back_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 5))) + + # The actual requirements for these rooms to avoid key-lock + set_rule(world.get_location('Ganons Tower - Firesnake Room', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or + ((item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) or item_name_in_location_names(state, 'Small Key (Ganons Tower)', player, [('Ganons Tower - Firesnake Room', player)])) and state._lttp_has_key('Small Key (Ganons Tower)', player, 5))) for location in randomizer_room_chests: - set_rule(world.get_location(location, player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 4) or ( - item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 3))) - - # Once again it is possible to need more than 3 keys... - set_rule(world.get_entrance('Ganons Tower (Tile Room) Key Door', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 3) and state.has('Fire Rod', player)) + set_rule(world.get_location(location, player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or ( + item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 6))) + + # Once again it is possible to need more than 7 keys... + set_rule(world.get_entrance('Ganons Tower (Tile Room) Key Door', player), lambda state: state.has('Fire Rod', player) and (state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or ( + item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(compass_room_chests, [player] * len(compass_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 5)))) + set_rule(world.get_entrance('Ganons Tower (Bottom) (East)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or ( + item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(back_chests, [player] * len(back_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 5))) # Actual requirements for location in compass_room_chests: - set_rule(world.get_location(location, player), lambda state: state.has('Fire Rod', player) and (state._lttp_has_key('Small Key (Ganons Tower)', player, 4) or ( - item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(compass_room_chests, [player] * len(compass_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 3)))) + set_rule(world.get_location(location, player), lambda state: state.has('Fire Rod', player) and (state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or ( + item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(compass_room_chests, [player] * len(compass_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 5)))) set_rule(world.get_location('Ganons Tower - Big Chest', player), lambda state: state.has('Big Key (Ganons Tower)', player)) @@ -408,9 +479,9 @@ def global_rules(world, player): set_rule(world.get_entrance('Ganons Tower Torch Rooms', player), lambda state: has_fire_source(state, player) and state.multiworld.get_entrance('Ganons Tower Torch Rooms', player).parent_region.dungeon.bosses['middle'].can_defeat(state)) set_rule(world.get_location('Ganons Tower - Pre-Moldorm Chest', player), - lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 3)) + lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7)) set_rule(world.get_entrance('Ganons Tower Moldorm Door', player), - lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 4)) + lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8)) set_rule(world.get_entrance('Ganons Tower Moldorm Gap', player), lambda state: state.has('Hookshot', player) and state.multiworld.get_entrance('Ganons Tower Moldorm Gap', player).parent_region.dungeon.bosses['top'].can_defeat(state)) set_defeat_dungeon_boss_rule(world.get_location('Agahnim 2', player)) @@ -797,15 +868,21 @@ def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=Fal if world.mode[player] != 'inverted': add_conditional_lamp('Agahnim 1', 'Agahnims Tower', 'Entrance') add_conditional_lamp('Castle Tower - Dark Maze', 'Agahnims Tower') + add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Agahnims Tower') + add_conditional_lamp('Castle Tower - Circle of Pots Key Drop', 'Agahnims Tower') else: add_conditional_lamp('Agahnim 1', 'Inverted Agahnims Tower', 'Entrance') add_conditional_lamp('Castle Tower - Dark Maze', 'Inverted Agahnims Tower') + add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Inverted Agahnims Tower') + add_conditional_lamp('Castle Tower - Circle of Pots Key Drop', 'Inverted Agahnims Tower') add_conditional_lamp('Old Man', 'Old Man Cave') add_conditional_lamp('Old Man Cave Exit (East)', 'Old Man Cave', 'Entrance') add_conditional_lamp('Death Mountain Return Cave Exit (East)', 'Death Mountain Return Cave', 'Entrance') add_conditional_lamp('Death Mountain Return Cave Exit (West)', 'Death Mountain Return Cave', 'Entrance') add_conditional_lamp('Old Man House Front to Back', 'Old Man House', 'Entrance') add_conditional_lamp('Old Man House Back to Front', 'Old Man House', 'Entrance') + add_conditional_lamp('Eastern Palace - Dark Square Pot Key', 'Eastern Palace') + add_conditional_lamp('Eastern Palace - Dark Eyegore Key Drop', 'Eastern Palace', 'Location', True) add_conditional_lamp('Eastern Palace - Big Key Chest', 'Eastern Palace') add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True) add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True) @@ -817,17 +894,32 @@ def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=Fal def open_rules(world, player): - # softlock protection as you can reach the sewers small key door with a guard drop key - set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player), - lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player)) + def basement_key_rule(state): + if location_item_name(state, 'Sewers - Key Rat Key Drop', player) == ("Small Key (Hyrule Castle)", player): + return state._lttp_has_key("Small Key (Hyrule Castle)", player, 2) + else: + return state._lttp_has_key("Small Key (Hyrule Castle)", player, 3) + + set_rule(world.get_location('Hyrule Castle - Boomerang Guard Key Drop', player), basement_key_rule) + set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player), basement_key_rule) + + set_rule(world.get_location('Sewers - Key Rat Key Drop', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3)) + + set_rule(world.get_location('Hyrule Castle - Big Key Drop', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4)) set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player), - lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player)) + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4) and + state.has('Big Key (Hyrule Castle)', player)) def swordless_rules(world, player): set_rule(world.get_entrance('Agahnim 1', player), lambda state: (state.has('Hammer', player) or state.has('Fire Rod', player) or can_shoot_arrows(state, player) or state.has('Cane of Somaria', player)) and state._lttp_has_key('Small Key (Agahnims Tower)', player, 2)) set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 3) and state.has('Fire Rod', player)) # no curtain - set_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) #in swordless mode bombos pads are present in the relevant parts of ice palace + + set_rule(world.get_location('Ice Palace - Jelly Key Drop', player), lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) + set_rule(world.get_entrance('Ice Palace (Second Section)', player), lambda state: (state.has('Fire Rod', player) or state.has('Bombos', player)) and state._lttp_has_key('Small Key (Ice Palace)', player)) + set_rule(world.get_entrance('Ganon Drop', player), lambda state: state.has('Hammer', player)) # need to damage ganon to get tiles to drop if world.mode[player] != 'inverted': @@ -850,11 +942,27 @@ def add_connection(parent_name, target_name, entrance_name, world, player): def standard_rules(world, player): add_connection('Menu', 'Hyrule Castle Secret Entrance', 'Uncle S&Q', world, player) world.get_entrance('Uncle S&Q', player).hide_path = True + set_rule(world.get_entrance('Throne Room', player), lambda state: state.can_reach('Hyrule Castle - Zelda\'s Chest', 'Location', player)) set_rule(world.get_entrance('Hyrule Castle Exit (East)', player), lambda state: state.can_reach('Sanctuary', 'Region', player)) set_rule(world.get_entrance('Hyrule Castle Exit (West)', player), lambda state: state.can_reach('Sanctuary', 'Region', player)) set_rule(world.get_entrance('Links House S&Q', player), lambda state: state.can_reach('Sanctuary', 'Region', player)) set_rule(world.get_entrance('Sanctuary S&Q', player), lambda state: state.can_reach('Sanctuary', 'Region', player)) + if world.smallkey_shuffle[player] != smallkey_shuffle.option_universal: + set_rule(world.get_location('Hyrule Castle - Boomerang Guard Key Drop', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1)) + set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1)) + + set_rule(world.get_location('Hyrule Castle - Big Key Drop', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2)) + set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2) and + state.has('Big Key (Hyrule Castle)', player)) + + set_rule(world.get_location('Sewers - Key Rat Key Drop', player), + lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3)) + def toss_junk_item(world, player): items = ['Rupees (20)', 'Bombs (3)', 'Arrows (10)', 'Rupees (5)', 'Rupee (1)', 'Bombs (10)', 'Single Arrow', 'Rupees (50)', 'Rupees (100)', 'Single Bomb', 'Bee', 'Bee Trap', @@ -869,7 +977,7 @@ def toss_junk_item(world, player): def set_trock_key_rules(world, player): # First set all relevant locked doors to impassible. - for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Pokey Room', 'Turtle Rock Big Key Door']: + for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']: set_rule(world.get_entrance(entrance, player), lambda state: False) all_state = world.get_all_state(use_cache=False) @@ -892,6 +1000,7 @@ def set_trock_key_rules(world, player): if can_reach_middle and not can_reach_back and not can_reach_front: normal_regions = all_state.reachable_regions[player].copy() set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: True) + set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: True) all_state.update_reachable_regions(player) front_locked_regions = all_state.reachable_regions[player].difference(normal_regions) front_locked_locations = set((location.name, player) for region in front_locked_regions for location in region.locations) @@ -903,26 +1012,33 @@ def set_trock_key_rules(world, player): # otherwise crystaroller room might not be properly marked as reachable through the back. set_rule(world.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player)) - # No matter what, the key requirement for going from the middle to the bottom should be three keys. - set_rule(world.get_entrance('Turtle Rock Dark Room Staircase', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)) + # No matter what, the key requirement for going from the middle to the bottom should be five keys. + set_rule(world.get_entrance('Turtle Rock Dark Room Staircase', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) # Now we need to set rules based on which entrances we have access to. The most important point is whether we have back access. If we have back access, we - # might open all the locked doors in any order so we need maximally restrictive rules. + # might open all the locked doors in any order, so we need maximally restrictive rules. if can_reach_back: - set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 4) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player))) - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4)) - # Only consider wasting the key on the Trinexx door for going from the front entrance to middle section. If other key doors are accessible, then these doors can be avoided - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)) - set_rule(world.get_entrance('Turtle Rock Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2)) + set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 6) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player))) + set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) + set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) + + set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) + set_rule(world.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) + set_rule(world.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) else: - # Middle to front requires 2 keys if the back is locked, otherwise 4 - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2) + # Middle to front requires 3 keys if the back is locked by this door, otherwise 5 + set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3) + if item_name_in_location_names(state, 'Big Key (Turtle Rock)', player, front_locked_locations.union({('Turtle Rock - Pokey 1 Key Drop', player)})) + else state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) + # Middle to front requires 4 keys if the back is locked by this door, otherwise 6 + set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4) if item_name_in_location_names(state, 'Big Key (Turtle Rock)', player, front_locked_locations) - else state._lttp_has_key('Small Key (Turtle Rock)', player, 4)) + else state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) - # Front to middle requires 2 keys (if the middle is accessible then these doors can be avoided, otherwise no keys can be wasted) - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2)) - set_rule(world.get_entrance('Turtle Rock Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 1)) + # Front to middle requires 3 keys (if the middle is accessible then these doors can be avoided, otherwise no keys can be wasted) + set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)) + set_rule(world.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2)) + set_rule(world.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 1)) set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, tr_big_key_chest_keys_needed(state))) @@ -933,8 +1049,8 @@ def tr_big_key_chest_keys_needed(state): if item in [('Small Key (Turtle Rock)', player)]: return 0 if item in [('Big Key (Turtle Rock)', player)]: - return 2 - return 4 + return 4 + return 6 # If TR is only accessible from the middle, the big key must be further restricted to prevent softlock potential if not can_reach_front and not world.smallkey_shuffle[player]: @@ -943,10 +1059,12 @@ def tr_big_key_chest_keys_needed(state): if not can_reach_big_chest: # Must not go in the Chain Chomps chest - only 2 other chests available and 3+ keys required for all other chests forbid_item(world.get_location('Turtle Rock - Chain Chomps', player), 'Big Key (Turtle Rock)', player) + forbid_item(world.get_location('Turtle Rock - Pokey 2 Key Drop', player), 'Big Key (Turtle Rock)', player) if world.accessibility[player] == 'locations' and world.goal[player] != 'icerodhunt': if world.bigkey_shuffle[player] and can_reach_big_chest: # Must not go in the dungeon - all 3 available chests (Chomps, Big Chest, Crystaroller) must be keys to access laser bridge, and the big key is required first for location in ['Turtle Rock - Chain Chomps', 'Turtle Rock - Compass Chest', + 'Turtle Rock - Pokey 1 Key Drop', 'Turtle Rock - Pokey 2 Key Drop', 'Turtle Rock - Roller Room - Left', 'Turtle Rock - Roller Room - Right']: forbid_item(world.get_location(location, player), 'Big Key (Turtle Rock)', player) else: diff --git a/worlds/alttp/UnderworldGlitchRules.py b/worlds/alttp/UnderworldGlitchRules.py index 11a95bf7cd6c..4b6bc54111e6 100644 --- a/worlds/alttp/UnderworldGlitchRules.py +++ b/worlds/alttp/UnderworldGlitchRules.py @@ -66,9 +66,12 @@ def underworld_glitches_rules(world, player): fix_fake_worlds = world.fix_fake_world[player] # Ice Palace Entrance Clip - # This is the easiest one since it's a simple internal clip. Just need to also add melting to freezor chest since it's otherwise assumed. - add_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: can_bomb_clip(state, world.get_region('Ice Palace (Entrance)', player), player), combine='or') + # This is the easiest one since it's a simple internal clip. + # Need to also add melting to freezor chest since it's otherwise assumed. + # Also can pick up the first jelly key from behind. + add_rule(world.get_entrance('Ice Palace (Main)', player), lambda state: can_bomb_clip(state, world.get_region('Ice Palace (Entrance)', player), player), combine='or') add_rule(world.get_location('Ice Palace - Freezor Chest', player), lambda state: can_melt_things(state, player)) + add_rule(world.get_location('Ice Palace - Jelly Key Drop', player), lambda state: can_bomb_clip(state, world.get_region('Ice Palace (Entrance)', player), player), combine='or') # Kiki Skip diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 8815fae092e6..65e36da3bd6a 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -15,7 +15,7 @@ from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem from .Options import alttp_options, smallkey_shuffle from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance, \ - is_main_entrance + is_main_entrance, key_drop_data from .Client import ALTTPSNIClient from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \ get_hash_string, get_base_rom_path, LttPDeltaPatch @@ -303,6 +303,8 @@ def generate_early(self): world.local_items[player].value |= self.item_name_groups[option.item_name_group] elif option == "different_world": world.non_local_items[player].value |= self.item_name_groups[option.item_name_group] + if world.mode[player] == "standard": + world.non_local_items[player].value -= {"Small Key (Hyrule Castle)"} elif option.in_dungeon: self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group] if option == "original_dungeon": @@ -478,12 +480,17 @@ def pre_fill(self): break else: raise FillError('Unable to place dungeon prizes') + if world.mode[player] == 'standard' and world.smallkey_shuffle[player] \ + and world.smallkey_shuffle[player] != smallkey_shuffle.option_universal and \ + world.smallkey_shuffle[player] != smallkey_shuffle.option_own_dungeons: + world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1 @classmethod def stage_pre_fill(cls, world): from .Dungeons import fill_dungeons_restrictive fill_dungeons_restrictive(world) + @classmethod def stage_post_fill(cls, world): ShopSlotFill(world) @@ -618,7 +625,6 @@ def create_item(self, name: str) -> Item: @classmethod def stage_fill_hook(cls, world, progitempool, usefulitempool, filleritempool, fill_locations): trash_counts = {} - for player in world.get_game_players("A Link to the Past"): if not world.ganonstower_vanilla[player] or \ world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}: @@ -792,7 +798,7 @@ def fill_slot_data(self): slot_options = ["crystals_needed_for_gt", "crystals_needed_for_ganon", "open_pyramid", "bigkey_shuffle", "smallkey_shuffle", "compass_shuffle", "map_shuffle", "progressive", "swordless", "retro_bow", "retro_caves", "shop_item_slots", - "boss_shuffle", "pot_shuffle", "enemy_shuffle"] + "boss_shuffle", "pot_shuffle", "enemy_shuffle", "key_drop_shuffle"] slot_data = {option_name: getattr(self.multiworld, option_name)[self.player].value for option_name in slot_options} @@ -803,11 +809,11 @@ def fill_slot_data(self): 'mm_medalion': self.multiworld.required_medallions[self.player][0], 'tr_medalion': self.multiworld.required_medallions[self.player][1], 'shop_shuffle': self.multiworld.shop_shuffle[self.player], - 'entrance_shuffle': self.multiworld.shuffle[self.player] + 'entrance_shuffle': self.multiworld.shuffle[self.player], } ) return slot_data - + def get_same_seed(world, seed_def: tuple) -> str: seeds: typing.Dict[tuple, str] = getattr(world, "__named_seeds", {}) diff --git a/worlds/alttp/docs/multiworld_de.md b/worlds/alttp/docs/multiworld_de.md index 38009fb58ed3..8ccd1a87a6b7 100644 --- a/worlds/alttp/docs/multiworld_de.md +++ b/worlds/alttp/docs/multiworld_de.md @@ -67,7 +67,7 @@ Wenn du eine Option nicht gewählt haben möchtest, setze ihren Wert einfach auf ### Überprüfung deiner YAML-Datei -Wenn man sichergehen will, ob die YAML-Datei funktioniert, kann man dies bei der [YAML Validator](/mysterycheck) Seite +Wenn man sichergehen will, ob die YAML-Datei funktioniert, kann man dies bei der [YAML Validator](/check) Seite tun. ## ein Einzelspielerspiel erstellen diff --git a/worlds/alttp/docs/multiworld_es.md b/worlds/alttp/docs/multiworld_es.md index 8576318bb997..37aeda2a63e5 100644 --- a/worlds/alttp/docs/multiworld_es.md +++ b/worlds/alttp/docs/multiworld_es.md @@ -82,7 +82,7 @@ debe tener al menos un valor mayor que cero, si no la generación fallará. ### Verificando tu archivo YAML Si quieres validar que tu fichero YAML para asegurarte que funciona correctamente, puedes hacerlo en la pagina -[YAML Validator](/mysterycheck). +[YAML Validator](/check). ## Generar una partida para un jugador diff --git a/worlds/alttp/docs/multiworld_fr.md b/worlds/alttp/docs/multiworld_fr.md index 329ca6537573..078a270f08b9 100644 --- a/worlds/alttp/docs/multiworld_fr.md +++ b/worlds/alttp/docs/multiworld_fr.md @@ -83,7 +83,7 @@ chaque paramètre il faut au moins une option qui soit paramétrée sur un nombr ### Vérifier son fichier YAML Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous pouvez le vérifier sur la page du -[Validateur de YAML](/mysterycheck). +[Validateur de YAML](/check). ## Générer une partie pour un joueur diff --git a/worlds/alttp/test/__init__.py b/worlds/alttp/test/__init__.py index e69de29bb2d1..5baaa7e88e61 100644 --- a/worlds/alttp/test/__init__.py +++ b/worlds/alttp/test/__init__.py @@ -0,0 +1,16 @@ +import unittest +from argparse import Namespace + +from BaseClasses import MultiWorld, CollectionState +from worlds import AutoWorldRegister + + +class LTTPTestBase(unittest.TestCase): + def world_setup(self): + self.multiworld = MultiWorld(1) + self.multiworld.state = CollectionState(self.multiworld) + self.multiworld.set_seed(None) + args = Namespace() + for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items(): + setattr(args, name, {1: option.from_any(getattr(option, "default"))}) + self.multiworld.set_options(args) diff --git a/worlds/alttp/test/dungeons/TestAgahnimsTower.py b/worlds/alttp/test/dungeons/TestAgahnimsTower.py index 6d0e1085f5cb..94e785485882 100644 --- a/worlds/alttp/test/dungeons/TestAgahnimsTower.py +++ b/worlds/alttp/test/dungeons/TestAgahnimsTower.py @@ -16,6 +16,18 @@ def testTower(self): ["Castle Tower - Dark Maze", False, [], ['Progressive Sword', 'Hammer', 'Progressive Bow', 'Fire Rod', 'Ice Rod', 'Cane of Somaria', 'Cane of Byrna']], ["Castle Tower - Dark Maze", True, ['Progressive Sword', 'Small Key (Agahnims Tower)', 'Lamp']], + ["Castle Tower - Dark Archer Key Drop", False, []], + ["Castle Tower - Dark Archer Key Drop", False, ['Small Key (Agahnims Tower)', 'Small Key (Agahnims Tower)']], + ["Castle Tower - Dark Archer Key Drop", False, [], ['Lamp']], + ["Castle Tower - Dark Archer Key Drop", False, [], ['Progressive Sword', 'Hammer', 'Progressive Bow', 'Fire Rod', 'Ice Rod', 'Cane of Somaria', 'Cane of Byrna']], + ["Castle Tower - Dark Archer Key Drop", True, ['Progressive Sword', 'Small Key (Agahnims Tower)', 'Small Key (Agahnims Tower)', 'Lamp']], + + ["Castle Tower - Circle of Pots Key Drop", False, []], + ["Castle Tower - Circle of Pots Key Drop", False, ['Small Key (Agahnims Tower)', 'Small Key (Agahnims Tower)']], + ["Castle Tower - Circle of Pots Key Drop", False, [], ['Lamp']], + ["Castle Tower - Circle of Pots Key Drop", False, [], ['Progressive Sword', 'Hammer', 'Progressive Bow', 'Fire Rod', 'Ice Rod', 'Cane of Somaria', 'Cane of Byrna']], + ["Castle Tower - Circle of Pots Key Drop", True, ['Progressive Sword', 'Small Key (Agahnims Tower)', 'Small Key (Agahnims Tower)', 'Lamp']], + ["Agahnim 1", False, []], ["Agahnim 1", False, ['Small Key (Agahnims Tower)'], ['Small Key (Agahnims Tower)']], ["Agahnim 1", False, [], ['Progressive Sword']], diff --git a/worlds/alttp/test/dungeons/TestDesertPalace.py b/worlds/alttp/test/dungeons/TestDesertPalace.py index 8423e681cf16..2d1951391177 100644 --- a/worlds/alttp/test/dungeons/TestDesertPalace.py +++ b/worlds/alttp/test/dungeons/TestDesertPalace.py @@ -18,12 +18,27 @@ def testDesertPalace(self): ["Desert Palace - Compass Chest", False, []], ["Desert Palace - Compass Chest", False, [], ['Small Key (Desert Palace)']], - ["Desert Palace - Compass Chest", True, ['Small Key (Desert Palace)']], + ["Desert Palace - Compass Chest", False, ['Progressive Sword', 'Hammer', 'Fire Rod', 'Ice Rod', 'Progressive Bow', 'Cane of Somaria', 'Cane of Byrna']], + ["Desert Palace - Compass Chest", False, ['Small Key (Desert Palace)']], + ["Desert Palace - Compass Chest", True, ['Progressive Sword', 'Small Key (Desert Palace)']], - #@todo: Require a real weapon for enemizer? ["Desert Palace - Big Key Chest", False, []], ["Desert Palace - Big Key Chest", False, [], ['Small Key (Desert Palace)']], - ["Desert Palace - Big Key Chest", True, ['Small Key (Desert Palace)']], + ["Desert Palace - Big Key Chest", False, ['Progressive Sword', 'Hammer', 'Fire Rod', 'Ice Rod', 'Progressive Bow', 'Cane of Somaria', 'Cane of Byrna']], + ["Desert Palace - Big Key Chest", False, ['Small Key (Desert Palace)']], + ["Desert Palace - Big Key Chest", True, ['Progressive Sword', 'Small Key (Desert Palace)']], + + ["Desert Palace - Desert Tiles 1 Pot Key", True, []], + + ["Desert Palace - Beamos Hall Pot Key", False, []], + ["Desert Palace - Beamos Hall Pot Key", False, [], ['Small Key (Desert Palace)']], + ["Desert Palace - Beamos Hall Pot Key", False, ['Progressive Sword', 'Hammer', 'Fire Rod', 'Ice Rod', 'Progressive Bow', 'Cane of Somaria', 'Cane of Byrna']], + ["Desert Palace - Beamos Hall Pot Key", True, ['Small Key (Desert Palace)', 'Progressive Sword']], + + ["Desert Palace - Desert Tiles 2 Pot Key", False, []], + ["Desert Palace - Desert Tiles 2 Pot Key", False, ['Small Key (Desert Palace)']], + ["Desert Palace - Desert Tiles 2 Pot Key", False, ['Progressive Sword', 'Hammer', 'Fire Rod', 'Ice Rod', 'Progressive Bow', 'Cane of Somaria', 'Cane of Byrna']], + ["Desert Palace - Desert Tiles 2 Pot Key", True, ['Small Key (Desert Palace)', 'Progressive Sword']], ["Desert Palace - Boss", False, []], ["Desert Palace - Boss", False, [], ['Small Key (Desert Palace)']], @@ -33,7 +48,6 @@ def testDesertPalace(self): ["Desert Palace - Boss", True, ['Small Key (Desert Palace)', 'Big Key (Desert Palace)', 'Fire Rod']], ["Desert Palace - Boss", True, ['Small Key (Desert Palace)', 'Big Key (Desert Palace)', 'Lamp', 'Progressive Sword']], ["Desert Palace - Boss", True, ['Small Key (Desert Palace)', 'Big Key (Desert Palace)', 'Lamp', 'Hammer']], - ["Desert Palace - Boss", True, ['Small Key (Desert Palace)', 'Big Key (Desert Palace)', 'Lamp', 'Ice Rod']], ["Desert Palace - Boss", True, ['Small Key (Desert Palace)', 'Big Key (Desert Palace)', 'Lamp', 'Cane of Somaria']], ["Desert Palace - Boss", True, ['Small Key (Desert Palace)', 'Big Key (Desert Palace)', 'Lamp', 'Cane of Byrna']], ]) \ No newline at end of file diff --git a/worlds/alttp/test/dungeons/TestDungeon.py b/worlds/alttp/test/dungeons/TestDungeon.py index 73ece1541733..94c30c349398 100644 --- a/worlds/alttp/test/dungeons/TestDungeon.py +++ b/worlds/alttp/test/dungeons/TestDungeon.py @@ -1,25 +1,16 @@ -import unittest -from argparse import Namespace - -from BaseClasses import MultiWorld, CollectionState, ItemClassification -from worlds.alttp.Dungeons import get_dungeon_item_pool +from BaseClasses import CollectionState, ItemClassification +from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import mandatory_connections, connect_simple from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import create_regions from worlds.alttp.Shops import create_shops -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestDungeon(unittest.TestCase): +class TestDungeon(LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.starting_regions = [] # Where to start exploring self.remove_exits = [] # Block dungeon exits self.multiworld.difficulty_requirements[1] = difficulties['normal'] @@ -61,6 +52,7 @@ def run_tests(self, access_pool): for item in items: item.classification = ItemClassification.progression - state.collect(item) + state.collect(item, event=True) # event=True prevents running sweep_for_events() and picking up + state.sweep_for_events() # key drop keys repeatedly - self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access) \ No newline at end of file + self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}") \ No newline at end of file diff --git a/worlds/alttp/test/dungeons/TestEasternPalace.py b/worlds/alttp/test/dungeons/TestEasternPalace.py index 0497a1132ee3..35c1b9928394 100644 --- a/worlds/alttp/test/dungeons/TestEasternPalace.py +++ b/worlds/alttp/test/dungeons/TestEasternPalace.py @@ -18,7 +18,8 @@ def testEastern(self): ["Eastern Palace - Big Key Chest", False, []], ["Eastern Palace - Big Key Chest", False, [], ['Lamp']], - ["Eastern Palace - Big Key Chest", True, ['Lamp']], + ["Eastern Palace - Big Key Chest", True, ['Lamp', 'Small Key (Eastern Palace)', 'Small Key (Eastern Palace)']], + ["Eastern Palace - Big Key Chest", True, ['Lamp', 'Big Key (Eastern Palace)']], #@todo: Advanced? ["Eastern Palace - Boss", False, []], diff --git a/worlds/alttp/test/dungeons/TestGanonsTower.py b/worlds/alttp/test/dungeons/TestGanonsTower.py index f81509273f00..d22dc92b366f 100644 --- a/worlds/alttp/test/dungeons/TestGanonsTower.py +++ b/worlds/alttp/test/dungeons/TestGanonsTower.py @@ -33,46 +33,50 @@ def testGanonsTower(self): ["Ganons Tower - Randomizer Room - Top Left", False, []], ["Ganons Tower - Randomizer Room - Top Left", False, [], ['Hammer']], ["Ganons Tower - Randomizer Room - Top Left", False, [], ['Hookshot']], - ["Ganons Tower - Randomizer Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Randomizer Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Randomizer Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Randomizer Room - Top Right", False, []], ["Ganons Tower - Randomizer Room - Top Right", False, [], ['Hammer']], ["Ganons Tower - Randomizer Room - Top Right", False, [], ['Hookshot']], - ["Ganons Tower - Randomizer Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Randomizer Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Randomizer Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Randomizer Room - Bottom Left", False, []], ["Ganons Tower - Randomizer Room - Bottom Left", False, [], ['Hammer']], ["Ganons Tower - Randomizer Room - Bottom Left", False, [], ['Hookshot']], - ["Ganons Tower - Randomizer Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Randomizer Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Randomizer Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Randomizer Room - Bottom Right", False, []], ["Ganons Tower - Randomizer Room - Bottom Right", False, [], ['Hammer']], ["Ganons Tower - Randomizer Room - Bottom Right", False, [], ['Hookshot']], - ["Ganons Tower - Randomizer Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Randomizer Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Randomizer Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Firesnake Room", False, []], ["Ganons Tower - Firesnake Room", False, [], ['Hammer']], ["Ganons Tower - Firesnake Room", False, [], ['Hookshot']], - ["Ganons Tower - Firesnake Room", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Firesnake Room", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Map Chest", False, []], ["Ganons Tower - Map Chest", False, [], ['Hammer']], ["Ganons Tower - Map Chest", False, [], ['Hookshot', 'Pegasus Boots']], - ["Ganons Tower - Map Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], - ["Ganons Tower - Map Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hammer', 'Pegasus Boots']], + ["Ganons Tower - Map Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Map Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hammer', 'Pegasus Boots']], ["Ganons Tower - Big Chest", False, []], ["Ganons Tower - Big Chest", False, [], ['Big Key (Ganons Tower)']], - ["Ganons Tower - Big Chest", True, ['Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], - ["Ganons Tower - Big Chest", True, ['Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Big Chest", True, ['Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], + ["Ganons Tower - Big Chest", True, ['Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Hope Room - Left", True, []], ["Ganons Tower - Hope Room - Right", True, []], ["Ganons Tower - Bob's Chest", False, []], - ["Ganons Tower - Bob's Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], - ["Ganons Tower - Bob's Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Bob's Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], + ["Ganons Tower - Bob's Chest", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Tile Room", False, []], ["Ganons Tower - Tile Room", False, [], ['Cane of Somaria']], @@ -81,34 +85,34 @@ def testGanonsTower(self): ["Ganons Tower - Compass Room - Top Left", False, []], ["Ganons Tower - Compass Room - Top Left", False, [], ['Cane of Somaria']], ["Ganons Tower - Compass Room - Top Left", False, [], ['Fire Rod']], - ["Ganons Tower - Compass Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Compass Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], ["Ganons Tower - Compass Room - Top Right", False, []], ["Ganons Tower - Compass Room - Top Right", False, [], ['Cane of Somaria']], ["Ganons Tower - Compass Room - Top Right", False, [], ['Fire Rod']], - ["Ganons Tower - Compass Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Compass Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], ["Ganons Tower - Compass Room - Bottom Left", False, []], ["Ganons Tower - Compass Room - Bottom Left", False, [], ['Cane of Somaria']], ["Ganons Tower - Compass Room - Bottom Left", False, [], ['Fire Rod']], - ["Ganons Tower - Compass Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Compass Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], ["Ganons Tower - Compass Room - Bottom Right", False, []], ["Ganons Tower - Compass Room - Bottom Right", False, [], ['Cane of Somaria']], ["Ganons Tower - Compass Room - Bottom Right", False, [], ['Fire Rod']], - ["Ganons Tower - Compass Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], + ["Ganons Tower - Compass Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Cane of Somaria']], ["Ganons Tower - Big Key Chest", False, []], - ["Ganons Tower - Big Key Chest", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], - ["Ganons Tower - Big Key Chest", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Big Key Chest", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], + ["Ganons Tower - Big Key Chest", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Big Key Room - Left", False, []], - ["Ganons Tower - Big Key Room - Left", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], - ["Ganons Tower - Big Key Room - Left", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Big Key Room - Left", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], + ["Ganons Tower - Big Key Room - Left", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Big Key Room - Right", False, []], - ["Ganons Tower - Big Key Room - Right", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], - ["Ganons Tower - Big Key Room - Right", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], + ["Ganons Tower - Big Key Room - Right", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Cane of Somaria', 'Fire Rod']], + ["Ganons Tower - Big Key Room - Right", True, ['Progressive Bow', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']], ["Ganons Tower - Mini Helmasaur Room - Left", False, []], ["Ganons Tower - Mini Helmasaur Room - Left", False, [], ['Progressive Bow']], @@ -128,8 +132,8 @@ def testGanonsTower(self): ["Ganons Tower - Pre-Moldorm Chest", False, [], ['Progressive Bow']], ["Ganons Tower - Pre-Moldorm Chest", False, [], ['Big Key (Ganons Tower)']], ["Ganons Tower - Pre-Moldorm Chest", False, [], ['Lamp', 'Fire Rod']], - ["Ganons Tower - Pre-Moldorm Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp']], - ["Ganons Tower - Pre-Moldorm Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod']], + ["Ganons Tower - Pre-Moldorm Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp']], + ["Ganons Tower - Pre-Moldorm Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod']], ["Ganons Tower - Validation Chest", False, []], ["Ganons Tower - Validation Chest", False, [], ['Hookshot']], @@ -137,8 +141,8 @@ def testGanonsTower(self): ["Ganons Tower - Validation Chest", False, [], ['Big Key (Ganons Tower)']], ["Ganons Tower - Validation Chest", False, [], ['Lamp', 'Fire Rod']], ["Ganons Tower - Validation Chest", False, [], ['Progressive Sword', 'Hammer']], - ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Progressive Sword']], - ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Progressive Sword']], - ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Hammer']], - ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Hammer']], + ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Progressive Sword']], + ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Progressive Sword']], + ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Hammer']], + ["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Hammer']], ]) \ No newline at end of file diff --git a/worlds/alttp/test/dungeons/TestIcePalace.py b/worlds/alttp/test/dungeons/TestIcePalace.py index 3c075fe5ea48..edc9f1fbae9e 100644 --- a/worlds/alttp/test/dungeons/TestIcePalace.py +++ b/worlds/alttp/test/dungeons/TestIcePalace.py @@ -72,8 +72,9 @@ def testIcePalace(self): ["Ice Palace - Boss", False, [], ['Big Key (Ice Palace)']], ["Ice Palace - Boss", False, [], ['Fire Rod', 'Bombos']], ["Ice Palace - Boss", False, [], ['Fire Rod', 'Progressive Sword']], - ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Fire Rod', 'Hammer', 'Small Key (Ice Palace)', 'Small Key (Ice Palace)']], - ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Fire Rod', 'Hammer', 'Cane of Somaria', 'Small Key (Ice Palace)']], - ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Bombos', 'Progressive Sword', 'Hammer', 'Small Key (Ice Palace)', 'Small Key (Ice Palace)']], - ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Bombos', 'Progressive Sword', 'Hammer', 'Cane of Somaria', 'Small Key (Ice Palace)']], + # need hookshot now to reach the right side for the 6th key + ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Fire Rod', 'Hammer', 'Small Key (Ice Palace)', 'Small Key (Ice Palace)', 'Hookshot']], + ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Fire Rod', 'Hammer', 'Cane of Somaria', 'Small Key (Ice Palace)', 'Hookshot']], + ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Bombos', 'Progressive Sword', 'Hammer', 'Small Key (Ice Palace)', 'Small Key (Ice Palace)', 'Hookshot']], + ["Ice Palace - Boss", True, ['Progressive Glove', 'Big Key (Ice Palace)', 'Bombos', 'Progressive Sword', 'Hammer', 'Cane of Somaria', 'Small Key (Ice Palace)', 'Hookshot']], ]) \ No newline at end of file diff --git a/worlds/alttp/test/dungeons/TestSkullWoods.py b/worlds/alttp/test/dungeons/TestSkullWoods.py index 2dab840cf449..7f97c4d2f823 100644 --- a/worlds/alttp/test/dungeons/TestSkullWoods.py +++ b/worlds/alttp/test/dungeons/TestSkullWoods.py @@ -26,18 +26,18 @@ def testSkullWoodsFrontOnly(self): ["Skull Woods - Big Chest", False, [], ['Never in logic']], ["Skull Woods - Compass Chest", False, []], - ["Skull Woods - Compass Chest", False, ['Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], - ["Skull Woods - Compass Chest", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)']], + ["Skull Woods - Compass Chest", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Compass Chest", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']], ["Skull Woods - Map Chest", True, []], ["Skull Woods - Pot Prison", False, []], - ["Skull Woods - Pot Prison", False, ['Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], - ["Skull Woods - Pot Prison", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)']], + ["Skull Woods - Pot Prison", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Pot Prison", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']], ["Skull Woods - Pinball Room", False, []], - ["Skull Woods - Pinball Room", False, [], ['Small Key (Skull Woods)']], - ["Skull Woods - Pinball Room", True, ['Small Key (Skull Woods)']] + ["Skull Woods - Pinball Room", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Pinball Room", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']], ]) def testSkullWoodsLeftOnly(self): @@ -50,8 +50,8 @@ def testSkullWoodsLeftOnly(self): ["Skull Woods - Compass Chest", True, []], ["Skull Woods - Map Chest", False, []], - ["Skull Woods - Map Chest", False, [], ['Small Key (Skull Woods)']], - ["Skull Woods - Map Chest", True, ['Small Key (Skull Woods)']], + ["Skull Woods - Map Chest", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Map Chest", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']], ["Skull Woods - Pot Prison", True, []], @@ -67,18 +67,18 @@ def testSkullWoodsBackOnly(self): ["Skull Woods - Big Chest", True, ['Big Key (Skull Woods)']], ["Skull Woods - Compass Chest", False, []], - ["Skull Woods - Compass Chest", False, ['Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], - ["Skull Woods - Compass Chest", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)']], + ["Skull Woods - Compass Chest", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Compass Chest", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']], ["Skull Woods - Map Chest", True, []], ["Skull Woods - Pot Prison", False, []], - ["Skull Woods - Pot Prison", False, ['Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], - ["Skull Woods - Pot Prison", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)']], + ["Skull Woods - Pot Prison", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Pot Prison", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']], ["Skull Woods - Pinball Room", False, []], - ["Skull Woods - Pinball Room", False, [], ['Small Key (Skull Woods)']], - ["Skull Woods - Pinball Room", True, ['Small Key (Skull Woods)']] + ["Skull Woods - Pinball Room", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Pinball Room", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)']] ]) def testSkullWoodsMiddle(self): @@ -94,6 +94,6 @@ def testSkullWoodsBack(self): ["Skull Woods - Boss", False, []], ["Skull Woods - Boss", False, [], ['Fire Rod']], ["Skull Woods - Boss", False, [], ['Progressive Sword']], - ["Skull Woods - Boss", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], - ["Skull Woods - Boss", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Fire Rod', 'Progressive Sword']], + ["Skull Woods - Boss", False, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)'], ['Small Key (Skull Woods)']], + ["Skull Woods - Boss", True, ['Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Small Key (Skull Woods)', 'Fire Rod', 'Progressive Sword']], ]) \ No newline at end of file diff --git a/worlds/alttp/test/dungeons/TestThievesTown.py b/worlds/alttp/test/dungeons/TestThievesTown.py index a7e20bc52014..01f1570a2581 100644 --- a/worlds/alttp/test/dungeons/TestThievesTown.py +++ b/worlds/alttp/test/dungeons/TestThievesTown.py @@ -6,10 +6,6 @@ class TestThievesTown(TestDungeon): def testThievesTown(self): self.starting_regions = ['Thieves Town (Entrance)'] self.run_tests([ - ["Thieves' Town - Attic", False, []], - ["Thieves' Town - Attic", False, [], ['Big Key (Thieves Town)']], - ["Thieves' Town - Attic", False, [], ['Small Key (Thieves Town)']], - ["Thieves' Town - Attic", True, ['Big Key (Thieves Town)', 'Small Key (Thieves Town)']], ["Thieves' Town - Big Key Chest", True, []], @@ -19,6 +15,19 @@ def testThievesTown(self): ["Thieves' Town - Ambush Chest", True, []], + ["Thieves' Town - Hallway Pot Key", False, []], + ["Thieves' Town - Hallway Pot Key", False, [], ['Big Key (Thieves Town)']], + ["Thieves' Town - Hallway Pot Key", True, ['Big Key (Thieves Town)']], + + ["Thieves' Town - Spike Switch Pot Key", False, []], + ["Thieves' Town - Spike Switch Pot Key", False, [], ['Big Key (Thieves Town)']], + ["Thieves' Town - Spike Switch Pot Key", True, ['Big Key (Thieves Town)']], + + ["Thieves' Town - Attic", False, []], + ["Thieves' Town - Attic", False, [], ['Big Key (Thieves Town)']], + ["Thieves' Town - Attic", False, [], ['Small Key (Thieves Town)']], + ["Thieves' Town - Attic", True, ['Big Key (Thieves Town)', 'Small Key (Thieves Town)']], + ["Thieves' Town - Big Chest", False, []], ["Thieves' Town - Big Chest", False, [], ['Big Key (Thieves Town)']], ["Thieves' Town - Big Chest", False, [], ['Small Key (Thieves Town)']], @@ -31,7 +40,6 @@ def testThievesTown(self): ["Thieves' Town - Boss", False, []], ["Thieves' Town - Boss", False, [], ['Big Key (Thieves Town)']], - ["Thieves' Town - Boss", False, [], ['Small Key (Thieves Town)']], ["Thieves' Town - Boss", False, [], ['Hammer', 'Progressive Sword', 'Cane of Somaria', 'Cane of Byrna']], ["Thieves' Town - Boss", True, ['Small Key (Thieves Town)', 'Big Key (Thieves Town)', 'Hammer']], ["Thieves' Town - Boss", True, ['Small Key (Thieves Town)', 'Big Key (Thieves Town)', 'Progressive Sword']], diff --git a/worlds/alttp/test/inverted/TestInverted.py b/worlds/alttp/test/inverted/TestInverted.py index ad7458202ead..f5608ba07b2d 100644 --- a/worlds/alttp/test/inverted/TestInverted.py +++ b/worlds/alttp/test/inverted/TestInverted.py @@ -1,6 +1,3 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_inverted_entrances from worlds.alttp.InvertedRegions import create_inverted_regions @@ -10,17 +7,12 @@ from worlds.alttp.Shops import create_shops from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase + -class TestInverted(TestBase): +class TestInverted(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.mode[1] = "inverted" create_inverted_regions(self.multiworld, 1) diff --git a/worlds/alttp/test/inverted/TestInvertedBombRules.py b/worlds/alttp/test/inverted/TestInvertedBombRules.py index 89c5d7860323..d9eacb5ad98b 100644 --- a/worlds/alttp/test/inverted/TestInvertedBombRules.py +++ b/worlds/alttp/test/inverted/TestInvertedBombRules.py @@ -1,27 +1,17 @@ -import unittest -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances, Inverted_LW_Dungeon_Entrances, Inverted_LW_Single_Cave_Doors, Inverted_Old_Man_Entrances, Inverted_DW_Entrances, Inverted_DW_Dungeon_Entrances, Inverted_DW_Single_Cave_Doors, \ Inverted_LW_Entrances_Must_Exit, Inverted_LW_Dungeon_Entrances_Must_Exit, Inverted_Bomb_Shop_Multi_Cave_Doors, Inverted_Bomb_Shop_Single_Cave_Doors, Blacksmith_Single_Cave_Doors, Inverted_Blacksmith_Multi_Cave_Doors from worlds.alttp.InvertedRegions import create_inverted_regions from worlds.alttp.ItemPool import difficulties from worlds.alttp.Rules import set_inverted_big_bomb_rules -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestInvertedBombRules(unittest.TestCase): +class TestInvertedBombRules(LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) + self.world_setup() self.multiworld.mode[1] = "inverted" - args = Namespace - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.difficulty_requirements[1] = difficulties['normal'] create_inverted_regions(self.multiworld, 1) self.multiworld.worlds[1].create_dungeons() diff --git a/worlds/alttp/test/inverted/TestInvertedTurtleRock.py b/worlds/alttp/test/inverted/TestInvertedTurtleRock.py index 533e3c650f70..fe8979c1ef02 100644 --- a/worlds/alttp/test/inverted/TestInvertedTurtleRock.py +++ b/worlds/alttp/test/inverted/TestInvertedTurtleRock.py @@ -18,10 +18,9 @@ def testTurtleRock(self): ["Turtle Rock - Chain Chomps", False, []], ["Turtle Rock - Chain Chomps", False, [], ['Magic Mirror', 'Cane of Somaria']], - # Item rando only needs 1 key. ER needs to consider the case when the back is accessible, but not the middle (key wasted on Trinexx door) ["Turtle Rock - Chain Chomps", False, ['Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Chain Chomps", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Chain Chomps", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], ["Turtle Rock - Chain Chomps", True, ['Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], @@ -55,8 +54,8 @@ def testTurtleRock(self): ["Turtle Rock - Big Chest", False, [], ['Big Key (Turtle Rock)']], ["Turtle Rock - Big Chest", False, [], ['Magic Mirror', 'Cane of Somaria']], ["Turtle Rock - Big Chest", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Somaria']], ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hookshot']], ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], @@ -66,8 +65,8 @@ def testTurtleRock(self): ["Turtle Rock - Big Key Chest", False, []], ["Turtle Rock - Big Key Chest", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']], - ["Turtle Rock - Big Key Chest", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Key Chest", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], # Mirror in from ledge, use left side entrance, have enough keys to get to the chest ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], @@ -80,8 +79,8 @@ def testTurtleRock(self): ["Turtle Rock - Crystaroller Room", False, [], ['Big Key (Turtle Rock)', 'Lamp']], ["Turtle Rock - Crystaroller Room", False, [], ['Magic Mirror', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], @@ -98,9 +97,9 @@ def testTurtleRock(self): ["Turtle Rock - Boss", False, [], ['Big Key (Turtle Rock)']], ["Turtle Rock - Boss", False, [], ['Magic Mirror', 'Lamp']], ["Turtle Rock - Boss", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Flute', 'Magic Mirror', 'Moon Pearl', 'Hookshot', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']] ]) @@ -115,12 +114,12 @@ def testEyeBridge(self): [location, False, [], ['Magic Mirror', 'Cane of Somaria']], [location, False, [], ['Magic Mirror', 'Lamp']], [location, False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], # Mirroring into Eye Bridge does not require Cane of Somaria [location, True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Byrna']], diff --git a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py index 72049e17742c..33e582298185 100644 --- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py +++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py @@ -1,27 +1,18 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_inverted_entrances from worlds.alttp.InvertedRegions import create_inverted_regions -from worlds.alttp.ItemPool import generate_itempool, difficulties +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import mark_light_world_regions from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase + -class TestInvertedMinor(TestBase): +class TestInvertedMinor(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.multiworld.mode[1] = "inverted" self.multiworld.logic[1] = "minorglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py b/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py index a25d89a6f4f9..d7b5c9f79788 100644 --- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py +++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py @@ -20,8 +20,8 @@ def testTurtleRock(self): ["Turtle Rock - Chain Chomps", False, [], ['Magic Mirror', 'Cane of Somaria']], # Item rando only needs 1 key. ER needs to consider the case when the back is accessible, but not the middle (key wasted on Trinexx door) ["Turtle Rock - Chain Chomps", False, ['Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Chain Chomps", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Chain Chomps", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], ["Turtle Rock - Chain Chomps", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], ["Turtle Rock - Chain Chomps", True, ['Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], @@ -55,8 +55,8 @@ def testTurtleRock(self): ["Turtle Rock - Big Chest", False, [], ['Big Key (Turtle Rock)']], ["Turtle Rock - Big Chest", False, [], ['Magic Mirror', 'Cane of Somaria']], ["Turtle Rock - Big Chest", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Somaria']], ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hookshot']], ["Turtle Rock - Big Chest", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], @@ -66,8 +66,8 @@ def testTurtleRock(self): ["Turtle Rock - Big Key Chest", False, []], ["Turtle Rock - Big Key Chest", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']], - ["Turtle Rock - Big Key Chest", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Key Chest", True, ['Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], # Mirror in from ledge, use left side entrance, have enough keys to get to the chest ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Big Key Chest", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], @@ -80,8 +80,8 @@ def testTurtleRock(self): ["Turtle Rock - Crystaroller Room", False, [], ['Big Key (Turtle Rock)', 'Lamp']], ["Turtle Rock - Crystaroller Room", False, [], ['Magic Mirror', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], @@ -98,9 +98,9 @@ def testTurtleRock(self): ["Turtle Rock - Boss", False, [], ['Big Key (Turtle Rock)']], ["Turtle Rock - Boss", False, [], ['Magic Mirror', 'Lamp']], ["Turtle Rock - Boss", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Flute', 'Magic Mirror', 'Moon Pearl', 'Hookshot', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']] ]) @@ -116,12 +116,12 @@ def testEyeBridge(self): [location, False, [], ['Magic Mirror', 'Cane of Somaria']], [location, False, [], ['Magic Mirror', 'Lamp']], [location, False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], # Mirroring into Eye Bridge does not require Cane of Somaria [location, True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Byrna']], diff --git a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py index 77a551db6f87..a4e84fce9b62 100644 --- a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py +++ b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py @@ -1,28 +1,18 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_inverted_entrances from worlds.alttp.InvertedRegions import create_inverted_regions -from worlds.alttp.ItemPool import generate_itempool, difficulties +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import mark_light_world_regions from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestInvertedOWG(TestBase): +class TestInvertedOWG(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.multiworld.logic[1] = "owglitches" self.multiworld.mode[1] = "inverted" self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/minor_glitches/TestMinor.py b/worlds/alttp/test/minor_glitches/TestMinor.py index fdf626fe9d37..d5cfd3095b9c 100644 --- a/worlds/alttp/test/minor_glitches/TestMinor.py +++ b/worlds/alttp/test/minor_glitches/TestMinor.py @@ -1,27 +1,15 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld -from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool -from worlds.alttp.EntranceShuffle import link_entrances +from worlds.alttp.Dungeons import get_dungeon_item_pool from worlds.alttp.InvertedRegions import mark_dark_world_regions from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory -from worlds.alttp.Regions import create_regions -from worlds.alttp.Shops import create_shops from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestMinor(TestBase): +class TestMinor(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.multiworld.logic[1] = "minorglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/alttp/test/owg/TestDungeons.py b/worlds/alttp/test/owg/TestDungeons.py index 284b489b1626..4f878969679a 100644 --- a/worlds/alttp/test/owg/TestDungeons.py +++ b/worlds/alttp/test/owg/TestDungeons.py @@ -6,6 +6,7 @@ class TestDungeons(TestVanillaOWG): def testFirstDungeonChests(self): self.run_location_tests([ ["Hyrule Castle - Map Chest", True, []], + ["Hyrule Castle - Map Guard Key Drop", True, []], ["Sanctuary", True, []], diff --git a/worlds/alttp/test/owg/TestVanillaOWG.py b/worlds/alttp/test/owg/TestVanillaOWG.py index c0888aa32fe6..37b0b6ccb868 100644 --- a/worlds/alttp/test/owg/TestVanillaOWG.py +++ b/worlds/alttp/test/owg/TestVanillaOWG.py @@ -1,24 +1,15 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import get_dungeon_item_pool from worlds.alttp.InvertedRegions import mark_dark_world_regions from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestVanillaOWG(TestBase): +class TestVanillaOWG(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.logic[1] = "owglitches" self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/alttp/test/vanilla/TestVanilla.py b/worlds/alttp/test/vanilla/TestVanilla.py index e338410df208..3c983e98504c 100644 --- a/worlds/alttp/test/vanilla/TestVanilla.py +++ b/worlds/alttp/test/vanilla/TestVanilla.py @@ -1,22 +1,14 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import get_dungeon_item_pool from worlds.alttp.InvertedRegions import mark_dark_world_regions from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase + -class TestVanilla(TestBase): +class TestVanilla(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - self.multiworld.set_seed(None) - args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) - self.multiworld.set_default_common_options() + self.world_setup() self.multiworld.logic[1] = "noglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/blasphemous/Rules.py b/worlds/blasphemous/Rules.py index 2ef36c575e63..4218fa94cf64 100644 --- a/worlds/blasphemous/Rules.py +++ b/worlds/blasphemous/Rules.py @@ -367,25 +367,25 @@ def has_boss_strength(name: str) -> bool: elif boss == "Graveyard": return ( has_boss_strength("amanecida") - and state.has_all({"D01BZ07S01[Santos]", "D02Z03S23[E]", "D02Z02S14[W]", "Wall Climb Ability"}, player) + and state.has_all({"D01Z06S01[Santos]", "D02Z03S23[E]", "D02Z02S14[W]", "Wall Climb Ability"}, player) ) elif boss == "Jondo": return ( has_boss_strength("amanecida") - and state.has("D01BZ07S01[Santos]", player) + and state.has("D01Z06S01[Santos]", player) and state.has_any({"D20Z01S05[W]", "D20Z01S05[E]"}, player) and state.has_any({"D03Z01S03[W]", "D03Z01S03[SW]"}, player) ) elif boss == "Patio": return ( has_boss_strength("amanecida") - and state.has_all({"D01BZ07S01[Santos]", "D06Z01S18[E]"}, player) + and state.has_all({"D01Z06S01[Santos]", "D06Z01S18[E]"}, player) and state.has_any({"D04Z01S04[W]", "D04Z01S04[E]", "D04Z01S04[Cherubs]"}, player) ) elif boss == "Wall": return ( has_boss_strength("amanecida") - and state.has_all({"D01BZ07S01[Santos]", "D09BZ01S01[Cell24]"}, player) + and state.has_all({"D01Z06S01[Santos]", "D09BZ01S01[Cell24]"}, player) and state.has_any({"D09Z01S01[W]", "D09Z01S01[E]"}, player) ) elif boss == "Hall": @@ -2451,6 +2451,8 @@ def rules(blasphemousworld): # Items set_rule(world.get_location("PotSS: 4th meeting with Redento", player), lambda state: redento(state, blasphemousworld, player, 4)) + set_rule(world.get_location("PotSS: Amanecida of the Chiselled Steel", player), + lambda state: can_beat_boss(state, "Patio", logic, player)) # No doors @@ -4191,8 +4193,9 @@ def rules(blasphemousworld): # Items set_rule(world.get_location("BotSS: Platforming gauntlet", player), lambda state: ( - state.has("D17BZ02S01[FrontR]", player) - or state.has_all({"Dash Ability", "Wall Climb Ability"}, player) + #state.has("D17BZ02S01[FrontR]", player) or + # TODO: actually fix this once door rando is real + state.has_all({"Dash Ability", "Wall Climb Ability"}, player) )) # Doors set_rule(world.get_entrance("D17BZ02S01[FrontR]", player), diff --git a/worlds/bumpstik/docs/setup_en.md b/worlds/bumpstik/docs/setup_en.md index 51334aa27701..e64a6e9f297d 100644 --- a/worlds/bumpstik/docs/setup_en.md +++ b/worlds/bumpstik/docs/setup_en.md @@ -1,21 +1,19 @@ ## Required Software -Download the game from the [Bumper Stickers GitHub releases page](https://github.com/FelicitusNeko/FlixelBumpStik/releases). - -*A web version will be made available on itch.io at a later time.* +Download the game from the [Bumper Stickers GitHub releases page](https://github.com/FelicitusNeko/FlixelBumpStik/releases), or from the [Bumper Stickers AP Itch page](https://kewliomzx.itch.io/bumpstik-ap), where you can also play it in your browser. ## Installation Procedures Simply download the latest version of Bumper Stickers from the link above, and extract it wherever you like. -- ⚠️ Do not extract Bumper Stickers to Program Files, as this will cause file access issues. +- ⚠️ It is not recommended to copy this game, or any files, directly into your Program Files folder under Windows. ## Joining a Multiworld Game -1. Run `BumpStik-AP.exe`. +1. Run `BumpStikAP.exe`. 2. Select "Archipelago Mode". 3. Enter your server details in the fields provided, and click "Start". - - ※ If you are connecting to a WSS server (such as archipelago.gg), specify `wss://` in the host name. Otherwise, the game will assume `ws://`. + - The game will attempt to automatically detect whether to connect via normal (WS) or secure (WSS) server, but you can specify `ws://` or `wss://` to prioritise one or the other. ## How to play Bumper Stickers (Classic) diff --git a/worlds/checksfinder/__init__.py b/worlds/checksfinder/__init__.py index 9ca16ca0d2ae..feff1486514a 100644 --- a/worlds/checksfinder/__init__.py +++ b/worlds/checksfinder/__init__.py @@ -45,7 +45,7 @@ def _get_checksfinder_data(self): 'race': self.multiworld.is_race, } - def generate_basic(self): + def create_items(self): # Generate item pool itempool = [] diff --git a/worlds/dark_souls_3/Locations.py b/worlds/dark_souls_3/Locations.py index 4e595ad36ac5..df241a5fd1fb 100644 --- a/worlds/dark_souls_3/Locations.py +++ b/worlds/dark_souls_3/Locations.py @@ -77,6 +77,7 @@ def get_name_to_id() -> dict: "Progressive Items 3", "Progressive Items 4", "Progressive Items DLC", + "Progressive Items Health", ] output = {} @@ -581,11 +582,7 @@ def get_name_to_id() -> dict: [DS3LocationData(f"Titanite Shard #{i + 1}", "Titanite Shard", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(26)] + [DS3LocationData(f"Large Titanite Shard #{i + 1}", "Large Titanite Shard", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(28)] + [DS3LocationData(f"Titanite Slab #{i + 1}", "Titanite Slab", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(3)] + - [DS3LocationData(f"Twinkling Titanite #{i + 1}", "Twinkling Titanite", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(15)] + - - # Healing - [DS3LocationData(f"Estus Shard #{i + 1}", "Estus Shard", DS3LocationCategory.HEALTH) for i in range(11)] + - [DS3LocationData(f"Undead Bone Shard #{i + 1}", "Undead Bone Shard", DS3LocationCategory.HEALTH) for i in range(10)], + [DS3LocationData(f"Twinkling Titanite #{i + 1}", "Twinkling Titanite", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(15)], "Progressive Items 2": [] + # Items @@ -683,7 +680,12 @@ def get_name_to_id() -> dict: [DS3LocationData(f"Dark Gem ${i + 1}", "Dark Gem", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(2)] + [DS3LocationData(f"Blood Gem ${i + 1}", "Blood Gem", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(1)] + [DS3LocationData(f"Blessed Gem ${i + 1}", "Blessed Gem", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(2)] + - [DS3LocationData(f"Hollow Gem ${i + 1}", "Hollow Gem", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(2)] + [DS3LocationData(f"Hollow Gem ${i + 1}", "Hollow Gem", DS3LocationCategory.PROGRESSIVE_ITEM) for i in range(2)], + + "Progressive Items Health": [] + + # Healing + [DS3LocationData(f"Estus Shard #{i + 1}", "Estus Shard", DS3LocationCategory.HEALTH) for i in range(11)] + + [DS3LocationData(f"Undead Bone Shard #{i + 1}", "Undead Bone Shard", DS3LocationCategory.HEALTH) for i in range(10)], } location_dictionary: Dict[str, DS3LocationData] = {} diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index faf6c2812177..195d319887d5 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -46,12 +46,20 @@ class DarkSouls3World(World): option_definitions = dark_souls_options topology_present: bool = True web = DarkSouls3Web() - data_version = 7 + data_version = 8 base_id = 100000 enabled_location_categories: Set[DS3LocationCategory] required_client_version = (0, 4, 2) item_name_to_id = DarkSouls3Item.get_name_to_id() location_name_to_id = DarkSouls3Location.get_name_to_id() + item_name_groups = { + "Cinders": { + "Cinders of a Lord - Abyss Watcher", + "Cinders of a Lord - Aldrich", + "Cinders of a Lord - Yhorm the Giant", + "Cinders of a Lord - Lothric Prince" + } + } def __init__(self, multiworld: MultiWorld, player: int): @@ -89,7 +97,7 @@ def generate_early(self): def create_regions(self): progressive_location_table = [] - if self.multiworld.enable_progressive_locations[self.player].value: + if self.multiworld.enable_progressive_locations[self.player]: progressive_location_table = [] + \ location_tables["Progressive Items 1"] + \ location_tables["Progressive Items 2"] + \ @@ -99,6 +107,9 @@ def create_regions(self): if self.multiworld.enable_dlc[self.player].value: progressive_location_table += location_tables["Progressive Items DLC"] + if self.multiworld.enable_health_upgrade_locations[self.player]: + progressive_location_table += location_tables["Progressive Items Health"] + # Create Vanilla Regions regions: Dict[str, Region] = {} regions["Menu"] = self.create_region("Menu", progressive_location_table) @@ -502,6 +513,15 @@ def fill_slot_data(self) -> Dict[str, object]: slot_data = { "options": { + "enable_weapon_locations": self.multiworld.enable_weapon_locations[self.player].value, + "enable_shield_locations": self.multiworld.enable_shield_locations[self.player].value, + "enable_armor_locations": self.multiworld.enable_armor_locations[self.player].value, + "enable_ring_locations": self.multiworld.enable_ring_locations[self.player].value, + "enable_spell_locations": self.multiworld.enable_spell_locations[self.player].value, + "enable_key_locations": self.multiworld.enable_key_locations[self.player].value, + "enable_boss_locations": self.multiworld.enable_boss_locations[self.player].value, + "enable_npc_locations": self.multiworld.enable_npc_locations[self.player].value, + "enable_misc_locations": self.multiworld.enable_misc_locations[self.player].value, "auto_equip": self.multiworld.auto_equip[self.player].value, "lock_equip": self.multiworld.lock_equip[self.player].value, "no_weapon_requirements": self.multiworld.no_weapon_requirements[self.player].value, diff --git a/worlds/dark_souls_3/docs/en_Dark Souls III.md b/worlds/dark_souls_3/docs/en_Dark Souls III.md index 3ad8236ccfae..e844925df1ea 100644 --- a/worlds/dark_souls_3/docs/en_Dark Souls III.md +++ b/worlds/dark_souls_3/docs/en_Dark Souls III.md @@ -7,20 +7,22 @@ config file. ## What does randomization do to this game? -In Dark Souls III, all unique items you can earn from a static corpse, a chest or the death of a Boss/NPC are -randomized. -An option is available from the settings page to also randomize the upgrade materials, the Estus shards and the -consumables. -Another option is available to randomize the level of the generated weapons(from +0 to +10/+5) +Items that can be picked up from static corpses, taken from chests, or earned from defeating enemies or NPCs can be +randomized. Common pickups like titanite shards or firebombs can be randomized as "progressive" items. That is, the +location "Titanite Shard #5" is the fifth titanite shard you pick up, no matter where it was from. This is also what +happens when you randomize Estus Shards and Undead Bone Shards. -To beat the game you need to collect the 4 "Cinders of a Lord" randomized in the multiworld -and kill the final boss "Soul of Cinder" +It's also possible to randomize the upgrade level of weapons and shields as well as their infusions (if they can have +one). Additionally, there are settings that can make the randomized experience more convenient or more interesting, such as +removing weapon requirements or auto-equipping whatever equipment you most recently received. + +The goal is to find the four "Cinders of a Lord" items randomized into the multiworld and defeat the Soul of Cinder. ## What Dark Souls III items can appear in other players' worlds? -Every unique item from Dark Souls III can appear in other player's worlds, such as a piece of armor, an upgraded weapon, -or a key item. +Practically anything can be found in other worlds including pieces of armor, upgraded weapons, key items, consumables, +spells, upgrade materials, etc... ## What does another world's item look like in Dark Souls III? -In Dark Souls III, items which need to be sent to other worlds appear as a Prism Stone. +In Dark Souls III, items which are sent to other worlds appear as Prism Stones. diff --git a/worlds/dkc3/docs/setup_en.md b/worlds/dkc3/docs/setup_en.md index 56ef80d4a55f..9c4197286eb9 100644 --- a/worlds/dkc3/docs/setup_en.md +++ b/worlds/dkc3/docs/setup_en.md @@ -50,7 +50,7 @@ them. Player settings page: [Donkey Kong Country 3 Player Settings Page](/games/ ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -validator page: [YAML Validation page](/mysterycheck) +validator page: [YAML Validation page](/check) ## Generating a Single-Player Game @@ -87,8 +87,7 @@ first time launching, you may be prompted to allow it to communicate through the 3. Click on **New Lua Script Window...** 4. In the new window, click **Browse...** 5. Select the connector lua file included with your client - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. 6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install. @@ -100,8 +99,7 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas 2. Load your ROM file if it hasn't already been loaded. If you changed your core preference after loading the ROM, don't forget to reload it (default hotkey: Ctrl+R). 3. Drag+drop the `Connector.lua` file included with your client onto the main EmuHawk window. - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. - You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `Connector.lua` with the file picker. diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 61f4cd30fabf..61d1be54cbd4 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -1,11 +1,12 @@ import csv import enum import math -from typing import Protocol, Union, Dict, List, Set -from BaseClasses import Item, ItemClassification -from . import Options, data from dataclasses import dataclass, field from random import Random +from typing import Dict, List, Set + +from BaseClasses import Item, ItemClassification +from . import Options, data class DLCQuestItem(Item): @@ -93,38 +94,35 @@ def create_trap_items(world, World_Options: Options.DLCQuestOptions, trap_needed def create_items(world, World_Options: Options.DLCQuestOptions, locations_count: int, random: Random): created_items = [] - if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[ - Options.Campaign] == Options.Campaign.option_both: + if World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign == Options.Campaign.option_both: for item in items_by_group[Group.DLCQuest]: if item.has_any_group(Group.DLC): created_items.append(world.create_item(item)) - if item.has_any_group(Group.Item) and World_Options[ - Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: + if item.has_any_group(Group.Item) and World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: created_items.append(world.create_item(item)) - if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin: - coin_bundle_needed = math.floor(825 / World_Options[Options.CoinSanityRange]) + if World_Options.coinsanity == Options.CoinSanity.option_coin: + coin_bundle_needed = math.floor(825 / World_Options.coinbundlequantity) for item in items_by_group[Group.DLCQuest]: if item.has_any_group(Group.Coin): for i in range(coin_bundle_needed): created_items.append(world.create_item(item)) - if 825 % World_Options[Options.CoinSanityRange] != 0: + if 825 % World_Options.coinbundlequantity != 0: created_items.append(world.create_item(item)) - if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die or World_Options[ - Options.Campaign] == Options.Campaign.option_both: + if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or + World_Options.campaign == Options.Campaign.option_both): for item in items_by_group[Group.Freemium]: if item.has_any_group(Group.DLC): created_items.append(world.create_item(item)) - if item.has_any_group(Group.Item) and World_Options[ - Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: + if item.has_any_group(Group.Item) and World_Options.item_shuffle == Options.ItemShuffle.option_shuffled: created_items.append(world.create_item(item)) - if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin: - coin_bundle_needed = math.floor(889 / World_Options[Options.CoinSanityRange]) + if World_Options.coinsanity == Options.CoinSanity.option_coin: + coin_bundle_needed = math.floor(889 / World_Options.coinbundlequantity) for item in items_by_group[Group.Freemium]: if item.has_any_group(Group.Coin): for i in range(coin_bundle_needed): created_items.append(world.create_item(item)) - if 889 % World_Options[Options.CoinSanityRange] != 0: + if 889 % World_Options.coinbundlequantity != 0: created_items.append(world.create_item(item)) trap_items = create_trap_items(world, World_Options, locations_count - len(created_items), random) diff --git a/worlds/dlcquest/Locations.py b/worlds/dlcquest/Locations.py index 08d37e781216..a9fdd00a202c 100644 --- a/worlds/dlcquest/Locations.py +++ b/worlds/dlcquest/Locations.py @@ -1,5 +1,4 @@ -from BaseClasses import Location, MultiWorld -from . import Options +from BaseClasses import Location class DLCQuestLocation(Location): diff --git a/worlds/dlcquest/Options.py b/worlds/dlcquest/Options.py index a1674a4d5a8b..ce728b4e9244 100644 --- a/worlds/dlcquest/Options.py +++ b/worlds/dlcquest/Options.py @@ -1,22 +1,6 @@ -from typing import Union, Dict, runtime_checkable, Protocol -from Options import Option, DeathLink, Choice, Toggle, SpecialRange from dataclasses import dataclass - -@runtime_checkable -class DLCQuestOption(Protocol): - internal_name: str - - -@dataclass -class DLCQuestOptions: - options: Dict[str, Union[bool, int]] - - def __getitem__(self, item: Union[str, DLCQuestOption]) -> Union[bool, int]: - if isinstance(item, DLCQuestOption): - item = item.internal_name - - return self.options.get(item, None) +from Options import Choice, DeathLink, PerGameCommonOptions, SpecialRange class DoubleJumpGlitch(Choice): @@ -94,31 +78,13 @@ class ItemShuffle(Choice): default = 0 -DLCQuest_options: Dict[str, type(Option)] = { - option.internal_name: option - for option in [ - DoubleJumpGlitch, - CoinSanity, - CoinSanityRange, - TimeIsMoney, - EndingChoice, - Campaign, - ItemShuffle, - ] -} -default_options = {option.internal_name: option.default for option in DLCQuest_options.values()} -DLCQuest_options["death_link"] = DeathLink - - -def fetch_options(world, player: int) -> DLCQuestOptions: - return DLCQuestOptions({option: get_option_value(world, player, option) for option in DLCQuest_options}) - - -def get_option_value(world, player: int, name: str) -> Union[bool, int]: - assert name in DLCQuest_options, f"{name} is not a valid option for DLC Quest." - - value = getattr(world, name) - - if issubclass(DLCQuest_options[name], Toggle): - return bool(value[player].value) - return value[player].value +@dataclass +class DLCQuestOptions(PerGameCommonOptions): + double_jump_glitch: DoubleJumpGlitch + coinsanity: CoinSanity + coinbundlequantity: CoinSanityRange + time_is_money: TimeIsMoney + ending_choice: EndingChoice + campaign: Campaign + item_shuffle: ItemShuffle + death_link: DeathLink diff --git a/worlds/dlcquest/Regions.py b/worlds/dlcquest/Regions.py index 8135a1c362c5..402ac722a0ad 100644 --- a/worlds/dlcquest/Regions.py +++ b/worlds/dlcquest/Regions.py @@ -1,325 +1,187 @@ import math -from BaseClasses import MultiWorld, Region, Location, Entrance, ItemClassification +from typing import List + +from BaseClasses import Entrance, MultiWorld, Region +from . import Options from .Locations import DLCQuestLocation, location_table from .Rules import create_event -from . import Options DLCQuestRegion = ["Movement Pack", "Behind Tree", "Psychological Warfare", "Double Jump Left", "Double Jump Behind the Tree", "The Forest", "Final Room"] -def add_coin_freemium(region: Region, Coin: int, player: int): - number_coin = f"{Coin} coins freemium" - location_coin = f"{region.name} coins freemium" - location = DLCQuestLocation(player, location_coin, None, region) - region.locations.append(location) - location.place_locked_item(create_event(player, number_coin)) +def add_coin_lfod(region: Region, coin: int, player: int): + add_coin(region, coin, player, " coins freemium") + + +def add_coin_dlcquest(region: Region, coin: int, player: int): + add_coin(region, coin, player, " coins") -def add_coin_dlcquest(region: Region, Coin: int, player: int): - number_coin = f"{Coin} coins" - location_coin = f"{region.name} coins" +def add_coin(region: Region, coin: int, player: int, suffix: str): + number_coin = f"{coin}{suffix}" + location_coin = f"{region.name}{suffix}" location = DLCQuestLocation(player, location_coin, None, region) region.locations.append(location) location.place_locked_item(create_event(player, number_coin)) -def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQuestOptions): - Regmenu = Region("Menu", player, world) - if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[ - Options.Campaign] == Options.Campaign.option_both: - Regmenu.exits += [Entrance(player, "DLC Quest Basic", Regmenu)] - if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die or World_Options[ - Options.Campaign] == Options.Campaign.option_both: - Regmenu.exits += [Entrance(player, "Live Freemium or Die", Regmenu)] - world.regions.append(Regmenu) - - if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[ - Options.Campaign] == Options.Campaign.option_both: - - Regmoveright = Region("Move Right", player, world, "Start of the basic game") - Locmoveright_name = ["Movement Pack", "Animation Pack", "Audio Pack", "Pause Menu Pack"] - Regmoveright.exits = [Entrance(player, "Moving", Regmoveright)] - Regmoveright.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regmoveright) for - loc_name in Locmoveright_name] - add_coin_dlcquest(Regmoveright, 4, player) - if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin: - coin_bundle_needed = math.floor(825 / World_Options[Options.CoinSanityRange]) - for i in range(coin_bundle_needed): - item_coin = f"DLC Quest: {World_Options[Options.CoinSanityRange] * (i + 1)} Coin" - Regmoveright.locations += [ - DLCQuestLocation(player, item_coin, location_table[item_coin], Regmoveright)] - if 825 % World_Options[Options.CoinSanityRange] != 0: - Regmoveright.locations += [ - DLCQuestLocation(player, "DLC Quest: 825 Coin", location_table["DLC Quest: 825 Coin"], - Regmoveright)] - world.regions.append(Regmoveright) - - Regmovpack = Region("Movement Pack", player, world) - Locmovpack_name = ["Time is Money Pack", "Psychological Warfare Pack", "Armor for your Horse Pack", - "Shepherd Sheep"] - if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: - Locmovpack_name += ["Sword"] - Regmovpack.exits = [Entrance(player, "Tree", Regmovpack), Entrance(player, "Cloud", Regmovpack)] - Regmovpack.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regmovpack) for loc_name - in Locmovpack_name] - add_coin_dlcquest(Regmovpack, 46, player) - world.regions.append(Regmovpack) - - Regbtree = Region("Behind Tree", player, world) - Locbtree_name = ["Double Jump Pack", "Map Pack", "Between Trees Sheep", "Hole in the Wall Sheep"] - if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: - Locbtree_name += ["Gun"] - Regbtree.exits = [Entrance(player, "Behind Tree Double Jump", Regbtree), - Entrance(player, "Forest Entrance", Regbtree)] - Regbtree.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regbtree) for loc_name in - Locbtree_name] - add_coin_dlcquest(Regbtree, 60, player) - world.regions.append(Regbtree) - - Regpsywarfare = Region("Psychological Warfare", player, world) - Locpsywarfare_name = ["West Cave Sheep"] - Regpsywarfare.exits = [Entrance(player, "Cloud Double Jump", Regpsywarfare)] - Regpsywarfare.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regpsywarfare) for - loc_name in Locpsywarfare_name] - add_coin_dlcquest(Regpsywarfare, 100, player) - world.regions.append(Regpsywarfare) - - Regdoubleleft = Region("Double Jump Total Left", player, world) - Locdoubleleft_name = ["Pet Pack", "Top Hat Pack", "North West Alcove Sheep"] - Regdoubleleft.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubleleft) for - loc_name in - Locdoubleleft_name] - Regdoubleleft.exits = [Entrance(player, "Cave Tree", Regdoubleleft), - Entrance(player, "Cave Roof", Regdoubleleft)] - add_coin_dlcquest(Regdoubleleft, 50, player) - world.regions.append(Regdoubleleft) - - Regdoubleleftcave = Region("Double Jump Total Left Cave", player, world) - Locdoubleleftcave_name = ["Top Hat Sheep"] - Regdoubleleftcave.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubleleftcave) - for loc_name in Locdoubleleftcave_name] - add_coin_dlcquest(Regdoubleleftcave, 9, player) - world.regions.append(Regdoubleleftcave) - - Regdoubleleftroof = Region("Double Jump Total Left Roof", player, world) - Locdoubleleftroof_name = ["North West Ceiling Sheep"] - Regdoubleleftroof.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubleleftroof) - for loc_name in Locdoubleleftroof_name] - add_coin_dlcquest(Regdoubleleftroof, 10, player) - world.regions.append(Regdoubleleftroof) - - Regdoubletree = Region("Double Jump Behind Tree", player, world) - Locdoubletree_name = ["Sexy Outfits Pack", "Double Jump Alcove Sheep", "Sexy Outfits Sheep"] - Regdoubletree.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regdoubletree) for - loc_name in - Locdoubletree_name] - Regdoubletree.exits = [Entrance(player, "True Double Jump", Regdoubletree)] - add_coin_dlcquest(Regdoubletree, 89, player) - world.regions.append(Regdoubletree) - - Regtruedoublejump = Region("True Double Jump Behind Tree", player, world) - Loctruedoublejump_name = ["Double Jump Floating Sheep", "Cutscene Sheep"] - Regtruedoublejump.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regtruedoublejump) - for loc_name in Loctruedoublejump_name] - add_coin_dlcquest(Regtruedoublejump, 7, player) - world.regions.append(Regtruedoublejump) - - Regforest = Region("The Forest", player, world) - Locforest_name = ["Gun Pack", "Night Map Pack"] - Regforest.exits = [Entrance(player, "Behind Ogre", Regforest), - Entrance(player, "Forest Double Jump", Regforest)] - Regforest.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regforest) for loc_name in - Locforest_name] - add_coin_dlcquest(Regforest, 171, player) - world.regions.append(Regforest) - - Regforestdoublejump = Region("The Forest whit double Jump", player, world) - Locforestdoublejump_name = ["The Zombie Pack", "Forest Low Sheep"] - Regforestdoublejump.exits = [Entrance(player, "Forest True Double Jump", Regforestdoublejump)] - Regforestdoublejump.locations += [ - DLCQuestLocation(player, loc_name, location_table[loc_name], Regforestdoublejump) for loc_name in - Locforestdoublejump_name] - add_coin_dlcquest(Regforestdoublejump, 76, player) - world.regions.append(Regforestdoublejump) - - Regforesttruedoublejump = Region("The Forest whit double Jump Part 2", player, world) - Locforesttruedoublejump_name = ["Forest High Sheep"] - Regforesttruedoublejump.locations += [ - DLCQuestLocation(player, loc_name, location_table[loc_name], Regforesttruedoublejump) - for loc_name in Locforesttruedoublejump_name] - add_coin_dlcquest(Regforesttruedoublejump, 203, player) - world.regions.append(Regforesttruedoublejump) - - Regfinalroom = Region("The Final Boss Room", player, world) - Locfinalroom_name = ["Finish the Fight Pack"] - Regfinalroom.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfinalroom) for - loc_name in - Locfinalroom_name] - world.regions.append(Regfinalroom) - - loc_win = DLCQuestLocation(player, "Winning Basic", None, world.get_region("The Final Boss Room", player)) - world.get_region("The Final Boss Room", player).locations.append(loc_win) - loc_win.place_locked_item(create_event(player, "Victory Basic")) - - world.get_entrance("DLC Quest Basic", player).connect(world.get_region("Move Right", player)) - - world.get_entrance("Moving", player).connect(world.get_region("Movement Pack", player)) - - world.get_entrance("Tree", player).connect(world.get_region("Behind Tree", player)) - - world.get_entrance("Cloud", player).connect(world.get_region("Psychological Warfare", player)) - - world.get_entrance("Cloud Double Jump", player).connect(world.get_region("Double Jump Total Left", player)) - - world.get_entrance("Cave Tree", player).connect(world.get_region("Double Jump Total Left Cave", player)) - - world.get_entrance("Cave Roof", player).connect(world.get_region("Double Jump Total Left Roof", player)) - - world.get_entrance("Forest Entrance", player).connect(world.get_region("The Forest", player)) - - world.get_entrance("Behind Tree Double Jump", player).connect( - world.get_region("Double Jump Behind Tree", player)) - - world.get_entrance("Behind Ogre", player).connect(world.get_region("The Final Boss Room", player)) - - world.get_entrance("Forest Double Jump", player).connect( - world.get_region("The Forest whit double Jump", player)) - - world.get_entrance("Forest True Double Jump", player).connect( - world.get_region("The Forest whit double Jump Part 2", player)) - - world.get_entrance("True Double Jump", player).connect(world.get_region("True Double Jump Behind Tree", player)) - - if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die or World_Options[ - Options.Campaign] == Options.Campaign.option_both: - - Regfreemiumstart = Region("Freemium Start", player, world) - Locfreemiumstart_name = ["Particles Pack", "Day One Patch Pack", "Checkpoint Pack", "Incredibly Important Pack", - "Nice Try", "Story is Important", "I Get That Reference!"] - if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: - Locfreemiumstart_name += ["Wooden Sword"] - Regfreemiumstart.exits = [Entrance(player, "Vines", Regfreemiumstart)] - Regfreemiumstart.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfreemiumstart) - for loc_name in - Locfreemiumstart_name] - add_coin_freemium(Regfreemiumstart, 50, player) - if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin: - coin_bundle_needed = math.floor(889 / World_Options[Options.CoinSanityRange]) - for i in range(coin_bundle_needed): - item_coin_freemium = f"Live Freemium or Die: {World_Options[Options.CoinSanityRange] * (i + 1)} Coin" - Regfreemiumstart.locations += [ - DLCQuestLocation(player, item_coin_freemium, location_table[item_coin_freemium], - Regfreemiumstart)] - if 889 % World_Options[Options.CoinSanityRange] != 0: - Regfreemiumstart.locations += [ - DLCQuestLocation(player, "Live Freemium or Die: 889 Coin", - location_table["Live Freemium or Die: 889 Coin"], - Regfreemiumstart)] - world.regions.append(Regfreemiumstart) - - Regbehindvine = Region("Behind the Vines", player, world) - Locbehindvine_name = ["Wall Jump Pack", "Health Bar Pack", "Parallax Pack"] - if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: - Locbehindvine_name += ["Pickaxe"] - Regbehindvine.exits = [Entrance(player, "Wall Jump Entrance", Regbehindvine)] - Regbehindvine.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regbehindvine) for - loc_name in Locbehindvine_name] - add_coin_freemium(Regbehindvine, 95, player) - world.regions.append(Regbehindvine) - - Regwalljump = Region("Wall Jump", player, world) - Locwalljump_name = ["Harmless Plants Pack", "Death of Comedy Pack", "Canadian Dialog Pack", "DLC NPC Pack"] - Regwalljump.exits = [Entrance(player, "Harmless Plants", Regwalljump), - Entrance(player, "Pickaxe Hard Cave", Regwalljump)] - Regwalljump.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regwalljump) for - loc_name in Locwalljump_name] - add_coin_freemium(Regwalljump, 150, player) - world.regions.append(Regwalljump) - - Regfakeending = Region("Fake Ending", player, world) - Locfakeending_name = ["Cut Content Pack", "Name Change Pack"] - Regfakeending.exits = [Entrance(player, "Name Change Entrance", Regfakeending), - Entrance(player, "Cut Content Entrance", Regfakeending)] - Regfakeending.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfakeending) for - loc_name in Locfakeending_name] - world.regions.append(Regfakeending) - - Reghardcave = Region("Hard Cave", player, world) - add_coin_freemium(Reghardcave, 20, player) - Reghardcave.exits = [Entrance(player, "Hard Cave Wall Jump", Reghardcave)] - world.regions.append(Reghardcave) - - Reghardcavewalljump = Region("Hard Cave Wall Jump", player, world) - Lochardcavewalljump_name = ["Increased HP Pack"] - Reghardcavewalljump.locations += [ - DLCQuestLocation(player, loc_name, location_table[loc_name], Reghardcavewalljump) for - loc_name in Lochardcavewalljump_name] - add_coin_freemium(Reghardcavewalljump, 130, player) - world.regions.append(Reghardcavewalljump) - - Regcutcontent = Region("Cut Content", player, world) - Loccutcontent_name = [] - if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: - Loccutcontent_name += ["Humble Indie Bindle"] - Regcutcontent.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regcutcontent) for - loc_name in Loccutcontent_name] - add_coin_freemium(Regcutcontent, 200, player) - world.regions.append(Regcutcontent) - - Regnamechange = Region("Name Change", player, world) - Locnamechange_name = [] - if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled: - Locnamechange_name += ["Box of Various Supplies"] - Regnamechange.exits = [Entrance(player, "Behind Rocks", Regnamechange)] - Regnamechange.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regnamechange) for - loc_name in Locnamechange_name] - world.regions.append(Regnamechange) - - Regtopright = Region("Top Right", player, world) - Loctopright_name = ["Season Pass", "High Definition Next Gen Pack"] - Regtopright.exits = [Entrance(player, "Blizzard", Regtopright)] - Regtopright.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regtopright) for - loc_name in Loctopright_name] - add_coin_freemium(Regtopright, 90, player) - world.regions.append(Regtopright) - - Regseason = Region("Season", player, world) - Locseason_name = ["Remove Ads Pack", "Not Exactly Noble"] - Regseason.exits = [Entrance(player, "Boss Door", Regseason)] - Regseason.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regseason) for - loc_name in Locseason_name] - add_coin_freemium(Regseason, 154, player) - world.regions.append(Regseason) - - Regfinalboss = Region("Final Boss", player, world) - Locfinalboss_name = ["Big Sword Pack", "Really Big Sword Pack", "Unfathomable Sword Pack"] - Regfinalboss.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfinalboss) for - loc_name in Locfinalboss_name] - world.regions.append(Regfinalboss) - - loc_wining = DLCQuestLocation(player, "Winning Freemium", None, world.get_region("Final Boss", player)) - world.get_region("Final Boss", player).locations.append(loc_wining) - loc_wining.place_locked_item(create_event(player, "Victory Freemium")) - - world.get_entrance("Live Freemium or Die", player).connect(world.get_region("Freemium Start", player)) - - world.get_entrance("Vines", player).connect(world.get_region("Behind the Vines", player)) - - world.get_entrance("Wall Jump Entrance", player).connect(world.get_region("Wall Jump", player)) - - world.get_entrance("Harmless Plants", player).connect(world.get_region("Fake Ending", player)) - - world.get_entrance("Pickaxe Hard Cave", player).connect(world.get_region("Hard Cave", player)) - - world.get_entrance("Hard Cave Wall Jump", player).connect(world.get_region("Hard Cave Wall Jump", player)) - - world.get_entrance("Name Change Entrance", player).connect(world.get_region("Name Change", player)) - - world.get_entrance("Cut Content Entrance", player).connect(world.get_region("Cut Content", player)) - - world.get_entrance("Behind Rocks", player).connect(world.get_region("Top Right", player)) - - world.get_entrance("Blizzard", player).connect(world.get_region("Season", player)) - - world.get_entrance("Boss Door", player).connect(world.get_region("Final Boss", player)) +def create_regions(multiworld: MultiWorld, player: int, world_options: Options.DLCQuestOptions): + region_menu = Region("Menu", player, multiworld) + has_campaign_basic = world_options.campaign == Options.Campaign.option_basic or world_options.campaign == Options.Campaign.option_both + has_campaign_lfod = world_options.campaign == Options.Campaign.option_live_freemium_or_die or world_options.campaign == Options.Campaign.option_both + has_coinsanity = world_options.coinsanity == Options.CoinSanity.option_coin + coin_bundle_size = world_options.coinbundlequantity.value + has_item_shuffle = world_options.item_shuffle == Options.ItemShuffle.option_shuffled + + multiworld.regions.append(region_menu) + + create_regions_basic_campaign(has_campaign_basic, region_menu, has_item_shuffle, has_coinsanity, coin_bundle_size, player, multiworld) + + create_regions_lfod_campaign(coin_bundle_size, has_campaign_lfod, has_coinsanity, has_item_shuffle, multiworld, player, region_menu) + + +def create_regions_basic_campaign(has_campaign_basic: bool, region_menu: Region, has_item_shuffle: bool, has_coinsanity: bool, + coin_bundle_size: int, player: int, world: MultiWorld): + if not has_campaign_basic: + return + + region_menu.exits += [Entrance(player, "DLC Quest Basic", region_menu)] + locations_move_right = ["Movement Pack", "Animation Pack", "Audio Pack", "Pause Menu Pack"] + region_move_right = create_region_and_locations_basic("Move Right", locations_move_right, ["Moving"], player, world, 4) + create_coinsanity_locations_dlc_quest(has_coinsanity, coin_bundle_size, player, region_move_right) + locations_movement_pack = ["Time is Money Pack", "Psychological Warfare Pack", "Armor for your Horse Pack", "Shepherd Sheep"] + locations_movement_pack += conditional_location(has_item_shuffle, "Sword") + create_region_and_locations_basic("Movement Pack", locations_movement_pack, ["Tree", "Cloud"], player, world, 46) + locations_behind_tree = ["Double Jump Pack", "Map Pack", "Between Trees Sheep", "Hole in the Wall Sheep"] + conditional_location(has_item_shuffle, "Gun") + create_region_and_locations_basic("Behind Tree", locations_behind_tree, ["Behind Tree Double Jump", "Forest Entrance"], player, world, 60) + create_region_and_locations_basic("Psychological Warfare", ["West Cave Sheep"], ["Cloud Double Jump"], player, world, 100) + locations_double_jump_left = ["Pet Pack", "Top Hat Pack", "North West Alcove Sheep"] + create_region_and_locations_basic("Double Jump Total Left", locations_double_jump_left, ["Cave Tree", "Cave Roof"], player, world, 50) + create_region_and_locations_basic("Double Jump Total Left Cave", ["Top Hat Sheep"], [], player, world, 9) + create_region_and_locations_basic("Double Jump Total Left Roof", ["North West Ceiling Sheep"], [], player, world, 10) + locations_double_jump_left_ceiling = ["Sexy Outfits Pack", "Double Jump Alcove Sheep", "Sexy Outfits Sheep"] + create_region_and_locations_basic("Double Jump Behind Tree", locations_double_jump_left_ceiling, ["True Double Jump"], player, world, 89) + create_region_and_locations_basic("True Double Jump Behind Tree", ["Double Jump Floating Sheep", "Cutscene Sheep"], [], player, world, 7) + create_region_and_locations_basic("The Forest", ["Gun Pack", "Night Map Pack"], ["Behind Ogre", "Forest Double Jump"], player, world, 171) + create_region_and_locations_basic("The Forest with double Jump", ["The Zombie Pack", "Forest Low Sheep"], ["Forest True Double Jump"], player, world, 76) + create_region_and_locations_basic("The Forest with double Jump Part 2", ["Forest High Sheep"], [], player, world, 203) + region_final_boss_room = create_region_and_locations_basic("The Final Boss Room", ["Finish the Fight Pack"], [], player, world) + + create_victory_event(region_final_boss_room, "Winning Basic", "Victory Basic", player) + + connect_entrances_basic(player, world) + + +def create_regions_lfod_campaign(coin_bundle_size, has_campaign_lfod, has_coinsanity, has_item_shuffle, multiworld, player, region_menu): + if not has_campaign_lfod: + return + + region_menu.exits += [Entrance(player, "Live Freemium or Die", region_menu)] + locations_lfod_start = ["Particles Pack", "Day One Patch Pack", "Checkpoint Pack", "Incredibly Important Pack", + "Nice Try", "Story is Important", "I Get That Reference!"] + conditional_location(has_item_shuffle, "Wooden Sword") + region_lfod_start = create_region_and_locations_lfod("Freemium Start", locations_lfod_start, ["Vines"], player, multiworld, 50) + create_coinsanity_locations_lfod(has_coinsanity, coin_bundle_size, player, region_lfod_start) + locations_behind_vines = ["Wall Jump Pack", "Health Bar Pack", "Parallax Pack"] + conditional_location(has_item_shuffle, "Pickaxe") + create_region_and_locations_lfod("Behind the Vines", locations_behind_vines, ["Wall Jump Entrance"], player, multiworld, 95) + locations_wall_jump = ["Harmless Plants Pack", "Death of Comedy Pack", "Canadian Dialog Pack", "DLC NPC Pack"] + create_region_and_locations_lfod("Wall Jump", locations_wall_jump, ["Harmless Plants", "Pickaxe Hard Cave"], player, multiworld, 150) + create_region_and_locations_lfod("Fake Ending", ["Cut Content Pack", "Name Change Pack"], ["Name Change Entrance", "Cut Content Entrance"], player, + multiworld) + create_region_and_locations_lfod("Hard Cave", [], ["Hard Cave Wall Jump"], player, multiworld, 20) + create_region_and_locations_lfod("Hard Cave Wall Jump", ["Increased HP Pack"], [], player, multiworld, 130) + create_region_and_locations_lfod("Cut Content", conditional_location(has_item_shuffle, "Humble Indie Bindle"), [], player, multiworld, 200) + create_region_and_locations_lfod("Name Change", conditional_location(has_item_shuffle, "Box of Various Supplies"), ["Behind Rocks"], player, multiworld) + create_region_and_locations_lfod("Top Right", ["Season Pass", "High Definition Next Gen Pack"], ["Blizzard"], player, multiworld, 90) + create_region_and_locations_lfod("Season", ["Remove Ads Pack", "Not Exactly Noble"], ["Boss Door"], player, multiworld, 154) + region_final_boss = create_region_and_locations_lfod("Final Boss", ["Big Sword Pack", "Really Big Sword Pack", "Unfathomable Sword Pack"], [], player, multiworld) + + create_victory_event(region_final_boss, "Winning Freemium", "Victory Freemium", player) + + connect_entrances_lfod(multiworld, player) + + +def conditional_location(condition: bool, location: str) -> List[str]: + return conditional_locations(condition, [location]) + + +def conditional_locations(condition: bool, locations: List[str]) -> List[str]: + return locations if condition else [] + + +def create_region_and_locations_basic(region_name: str, locations: List[str], exits: List[str], player: int, multiworld: MultiWorld, + number_coins: int = 0) -> Region: + return create_region_and_locations(region_name, locations, exits, player, multiworld, number_coins, 0) + + +def create_region_and_locations_lfod(region_name: str, locations: List[str], exits: List[str], player: int, multiworld: MultiWorld, + number_coins: int = 0) -> Region: + return create_region_and_locations(region_name, locations, exits, player, multiworld, 0, number_coins) + + +def create_region_and_locations(region_name: str, locations: List[str], exits: List[str], player: int, multiworld: MultiWorld, + number_coins_basic: int, number_coins_lfod: int) -> Region: + region = Region(region_name, player, multiworld) + region.exits = [Entrance(player, exit_name, region) for exit_name in exits] + region.locations += [DLCQuestLocation(player, name, location_table[name], region) for name in locations] + if number_coins_basic > 0: + add_coin_dlcquest(region, number_coins_basic, player) + if number_coins_lfod > 0: + add_coin_lfod(region, number_coins_lfod, player) + multiworld.regions.append(region) + return region + + +def create_victory_event(region_victory: Region, event_name: str, item_name: str, player: int): + location_victory = DLCQuestLocation(player, event_name, None, region_victory) + region_victory.locations.append(location_victory) + location_victory.place_locked_item(create_event(player, item_name)) + + +def connect_entrances_basic(player, world): + world.get_entrance("DLC Quest Basic", player).connect(world.get_region("Move Right", player)) + world.get_entrance("Moving", player).connect(world.get_region("Movement Pack", player)) + world.get_entrance("Tree", player).connect(world.get_region("Behind Tree", player)) + world.get_entrance("Cloud", player).connect(world.get_region("Psychological Warfare", player)) + world.get_entrance("Cloud Double Jump", player).connect(world.get_region("Double Jump Total Left", player)) + world.get_entrance("Cave Tree", player).connect(world.get_region("Double Jump Total Left Cave", player)) + world.get_entrance("Cave Roof", player).connect(world.get_region("Double Jump Total Left Roof", player)) + world.get_entrance("Forest Entrance", player).connect(world.get_region("The Forest", player)) + world.get_entrance("Behind Tree Double Jump", player).connect(world.get_region("Double Jump Behind Tree", player)) + world.get_entrance("Behind Ogre", player).connect(world.get_region("The Final Boss Room", player)) + world.get_entrance("Forest Double Jump", player).connect(world.get_region("The Forest with double Jump", player)) + world.get_entrance("Forest True Double Jump", player).connect(world.get_region("The Forest with double Jump Part 2", player)) + world.get_entrance("True Double Jump", player).connect(world.get_region("True Double Jump Behind Tree", player)) + + +def connect_entrances_lfod(multiworld, player): + multiworld.get_entrance("Live Freemium or Die", player).connect(multiworld.get_region("Freemium Start", player)) + multiworld.get_entrance("Vines", player).connect(multiworld.get_region("Behind the Vines", player)) + multiworld.get_entrance("Wall Jump Entrance", player).connect(multiworld.get_region("Wall Jump", player)) + multiworld.get_entrance("Harmless Plants", player).connect(multiworld.get_region("Fake Ending", player)) + multiworld.get_entrance("Pickaxe Hard Cave", player).connect(multiworld.get_region("Hard Cave", player)) + multiworld.get_entrance("Hard Cave Wall Jump", player).connect(multiworld.get_region("Hard Cave Wall Jump", player)) + multiworld.get_entrance("Name Change Entrance", player).connect(multiworld.get_region("Name Change", player)) + multiworld.get_entrance("Cut Content Entrance", player).connect(multiworld.get_region("Cut Content", player)) + multiworld.get_entrance("Behind Rocks", player).connect(multiworld.get_region("Top Right", player)) + multiworld.get_entrance("Blizzard", player).connect(multiworld.get_region("Season", player)) + multiworld.get_entrance("Boss Door", player).connect(multiworld.get_region("Final Boss", player)) + + +def create_coinsanity_locations_dlc_quest(has_coinsanity: bool, coin_bundle_size: int, player: int, region_move_right: Region): + create_coinsanity_locations(has_coinsanity, coin_bundle_size, player, region_move_right, 825, "DLC Quest") + + +def create_coinsanity_locations_lfod(has_coinsanity: bool, coin_bundle_size: int, player: int, region_lfod_start: Region): + create_coinsanity_locations(has_coinsanity, coin_bundle_size, player, region_lfod_start, 889, "Live Freemium or Die") + + +def create_coinsanity_locations(has_coinsanity: bool, coin_bundle_size: int, player: int, region: Region, last_coin_number: int, campaign_prefix: str): + if not has_coinsanity: + return + + coin_bundle_needed = math.ceil(last_coin_number / coin_bundle_size) + for i in range(1, coin_bundle_needed + 1): + number_coins = min(last_coin_number, coin_bundle_size * i) + item_coin = f"{campaign_prefix}: {number_coins} Coin" + region.locations += [DLCQuestLocation(player, item_coin, location_table[item_coin], region)] diff --git a/worlds/dlcquest/Rules.py b/worlds/dlcquest/Rules.py index d2495121f4e8..c5fdfe8282c4 100644 --- a/worlds/dlcquest/Rules.py +++ b/worlds/dlcquest/Rules.py @@ -1,10 +1,10 @@ import math import re -from .Locations import DLCQuestLocation -from ..generic.Rules import add_rule, set_rule, item_name_in_locations -from .Items import DLCQuestItem + from BaseClasses import ItemClassification +from worlds.generic.Rules import add_rule, item_name_in_locations, set_rule from . import Options +from .Items import DLCQuestItem def create_event(player, event: str): @@ -42,7 +42,7 @@ def has_coin(state, player: int, coins: int): def set_basic_rules(World_Options, has_enough_coin, player, world): - if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die: + if World_Options.campaign == Options.Campaign.option_live_freemium_or_die: return set_basic_entrance_rules(player, world) set_basic_self_obtained_items_rules(World_Options, player, world) @@ -66,12 +66,12 @@ def set_basic_entrance_rules(player, world): def set_basic_self_obtained_items_rules(World_Options, player, world): - if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_disabled: + if World_Options.item_shuffle != Options.ItemShuffle.option_disabled: return set_rule(world.get_entrance("Behind Ogre", player), lambda state: state.has("Gun Pack", player)) - if World_Options[Options.TimeIsMoney] == Options.TimeIsMoney.option_required: + if World_Options.time_is_money == Options.TimeIsMoney.option_required: set_rule(world.get_entrance("Tree", player), lambda state: state.has("Time is Money Pack", player)) set_rule(world.get_entrance("Cave Tree", player), @@ -87,7 +87,7 @@ def set_basic_self_obtained_items_rules(World_Options, player, world): def set_basic_shuffled_items_rules(World_Options, player, world): - if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_shuffled: + if World_Options.item_shuffle != Options.ItemShuffle.option_shuffled: return set_rule(world.get_entrance("Behind Ogre", player), lambda state: state.has("Gun", player)) @@ -108,13 +108,13 @@ def set_basic_shuffled_items_rules(World_Options, player, world): set_rule(world.get_location("Gun", player), lambda state: state.has("Gun Pack", player)) - if World_Options[Options.TimeIsMoney] == Options.TimeIsMoney.option_required: + if World_Options.time_is_money == Options.TimeIsMoney.option_required: set_rule(world.get_location("Sword", player), lambda state: state.has("Time is Money Pack", player)) def set_double_jump_glitchless_rules(World_Options, player, world): - if World_Options[Options.DoubleJumpGlitch] != Options.DoubleJumpGlitch.option_none: + if World_Options.double_jump_glitch != Options.DoubleJumpGlitch.option_none: return set_rule(world.get_entrance("Cloud Double Jump", player), lambda state: state.has("Double Jump Pack", player)) @@ -123,7 +123,7 @@ def set_double_jump_glitchless_rules(World_Options, player, world): def set_easy_double_jump_glitch_rules(World_Options, player, world): - if World_Options[Options.DoubleJumpGlitch] == Options.DoubleJumpGlitch.option_all: + if World_Options.double_jump_glitch == Options.DoubleJumpGlitch.option_all: return set_rule(world.get_entrance("Behind Tree Double Jump", player), lambda state: state.has("Double Jump Pack", player)) @@ -132,70 +132,70 @@ def set_easy_double_jump_glitch_rules(World_Options, player, world): def self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, player, world): - if World_Options[Options.CoinSanity] != Options.CoinSanity.option_coin: + if World_Options.coinsanity != Options.CoinSanity.option_coin: return - number_of_bundle = math.floor(825 / World_Options[Options.CoinSanityRange]) + number_of_bundle = math.floor(825 / World_Options.coinbundlequantity) for i in range(number_of_bundle): - item_coin = f"DLC Quest: {World_Options[Options.CoinSanityRange] * (i + 1)} Coin" + item_coin = f"DLC Quest: {World_Options.coinbundlequantity * (i + 1)} Coin" set_rule(world.get_location(item_coin, player), - has_enough_coin(player, World_Options[Options.CoinSanityRange] * (i + 1))) - if 825 % World_Options[Options.CoinSanityRange] != 0: + has_enough_coin(player, World_Options.coinbundlequantity * (i + 1))) + if 825 % World_Options.coinbundlequantity != 0: set_rule(world.get_location("DLC Quest: 825 Coin", player), has_enough_coin(player, 825)) set_rule(world.get_location("Movement Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(4 / World_Options[Options.CoinSanityRange]))) + math.ceil(4 / World_Options.coinbundlequantity))) set_rule(world.get_location("Animation Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Audio Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Pause Menu Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Time is Money Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(20 / World_Options[Options.CoinSanityRange]))) + math.ceil(20 / World_Options.coinbundlequantity))) set_rule(world.get_location("Double Jump Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(100 / World_Options[Options.CoinSanityRange]))) + math.ceil(100 / World_Options.coinbundlequantity))) set_rule(world.get_location("Pet Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Sexy Outfits Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Top Hat Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Map Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(140 / World_Options[Options.CoinSanityRange]))) + math.ceil(140 / World_Options.coinbundlequantity))) set_rule(world.get_location("Gun Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(75 / World_Options[Options.CoinSanityRange]))) + math.ceil(75 / World_Options.coinbundlequantity))) set_rule(world.get_location("The Zombie Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Night Map Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(75 / World_Options[Options.CoinSanityRange]))) + math.ceil(75 / World_Options.coinbundlequantity))) set_rule(world.get_location("Psychological Warfare Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(50 / World_Options[Options.CoinSanityRange]))) + math.ceil(50 / World_Options.coinbundlequantity))) set_rule(world.get_location("Armor for your Horse Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(250 / World_Options[Options.CoinSanityRange]))) + math.ceil(250 / World_Options.coinbundlequantity))) set_rule(world.get_location("Finish the Fight Pack", player), lambda state: state.has("DLC Quest: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) def set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player, world): - if World_Options[Options.CoinSanity] != Options.CoinSanity.option_none: + if World_Options.coinsanity != Options.CoinSanity.option_none: return set_rule(world.get_location("Movement Pack", player), has_enough_coin(player, 4)) @@ -232,17 +232,17 @@ def set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player, def self_basic_win_condition(World_Options, player, world): - if World_Options[Options.EndingChoice] == Options.EndingChoice.option_any: + if World_Options.ending_choice == Options.EndingChoice.option_any: set_rule(world.get_location("Winning Basic", player), lambda state: state.has("Finish the Fight Pack", player)) - if World_Options[Options.EndingChoice] == Options.EndingChoice.option_true: + if World_Options.ending_choice == Options.EndingChoice.option_true: set_rule(world.get_location("Winning Basic", player), lambda state: state.has("Armor for your Horse Pack", player) and state.has("Finish the Fight Pack", player)) def set_lfod_rules(World_Options, has_enough_coin_freemium, player, world): - if World_Options[Options.Campaign] == Options.Campaign.option_basic: + if World_Options.campaign == Options.Campaign.option_basic: return set_lfod_entrance_rules(player, world) set_boss_door_requirements_rules(player, world) @@ -297,7 +297,7 @@ def set_boss_door_requirements_rules(player, world): def set_lfod_self_obtained_items_rules(World_Options, player, world): - if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_disabled: + if World_Options.item_shuffle != Options.ItemShuffle.option_disabled: return set_rule(world.get_entrance("Vines", player), lambda state: state.has("Incredibly Important Pack", player)) @@ -309,7 +309,7 @@ def set_lfod_self_obtained_items_rules(World_Options, player, world): def set_lfod_shuffled_items_rules(World_Options, player, world): - if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_shuffled: + if World_Options.item_shuffle != Options.ItemShuffle.option_shuffled: return set_rule(world.get_entrance("Vines", player), lambda state: state.has("Wooden Sword", player) or state.has("Pickaxe", player)) @@ -328,79 +328,79 @@ def set_lfod_shuffled_items_rules(World_Options, player, world): def self_lfod_coinsanity_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world): - if World_Options[Options.CoinSanity] != Options.CoinSanity.option_coin: + if World_Options.coinsanity != Options.CoinSanity.option_coin: return - number_of_bundle = math.floor(889 / World_Options[Options.CoinSanityRange]) + number_of_bundle = math.floor(889 / World_Options.coinbundlequantity) for i in range(number_of_bundle): item_coin_freemium = "Live Freemium or Die: number Coin" - item_coin_loc_freemium = re.sub("number", str(World_Options[Options.CoinSanityRange] * (i + 1)), + item_coin_loc_freemium = re.sub("number", str(World_Options.coinbundlequantity * (i + 1)), item_coin_freemium) set_rule(world.get_location(item_coin_loc_freemium, player), - has_enough_coin_freemium(player, World_Options[Options.CoinSanityRange] * (i + 1))) - if 889 % World_Options[Options.CoinSanityRange] != 0: + has_enough_coin_freemium(player, World_Options.coinbundlequantity * (i + 1))) + if 889 % World_Options.coinbundlequantity != 0: set_rule(world.get_location("Live Freemium or Die: 889 Coin", player), has_enough_coin_freemium(player, 889)) add_rule(world.get_entrance("Boss Door", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(889 / World_Options[Options.CoinSanityRange]))) + math.ceil(889 / World_Options.coinbundlequantity))) set_rule(world.get_location("Particles Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Day One Patch Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Checkpoint Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Incredibly Important Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(15 / World_Options[Options.CoinSanityRange]))) + math.ceil(15 / World_Options.coinbundlequantity))) set_rule(world.get_location("Wall Jump Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(35 / World_Options[Options.CoinSanityRange]))) + math.ceil(35 / World_Options.coinbundlequantity))) set_rule(world.get_location("Health Bar Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Parallax Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(5 / World_Options[Options.CoinSanityRange]))) + math.ceil(5 / World_Options.coinbundlequantity))) set_rule(world.get_location("Harmless Plants Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(130 / World_Options[Options.CoinSanityRange]))) + math.ceil(130 / World_Options.coinbundlequantity))) set_rule(world.get_location("Death of Comedy Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(15 / World_Options[Options.CoinSanityRange]))) + math.ceil(15 / World_Options.coinbundlequantity))) set_rule(world.get_location("Canadian Dialog Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(10 / World_Options[Options.CoinSanityRange]))) + math.ceil(10 / World_Options.coinbundlequantity))) set_rule(world.get_location("DLC NPC Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(15 / World_Options[Options.CoinSanityRange]))) + math.ceil(15 / World_Options.coinbundlequantity))) set_rule(world.get_location("Cut Content Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(40 / World_Options[Options.CoinSanityRange]))) + math.ceil(40 / World_Options.coinbundlequantity))) set_rule(world.get_location("Name Change Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(150 / World_Options[Options.CoinSanityRange]))) + math.ceil(150 / World_Options.coinbundlequantity))) set_rule(world.get_location("Season Pass", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(199 / World_Options[Options.CoinSanityRange]))) + math.ceil(199 / World_Options.coinbundlequantity))) set_rule(world.get_location("High Definition Next Gen Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(20 / World_Options[Options.CoinSanityRange]))) + math.ceil(20 / World_Options.coinbundlequantity))) set_rule(world.get_location("Increased HP Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(10 / World_Options[Options.CoinSanityRange]))) + math.ceil(10 / World_Options.coinbundlequantity))) set_rule(world.get_location("Remove Ads Pack", player), lambda state: state.has("Live Freemium or Die: Coin Bundle", player, - math.ceil(25 / World_Options[Options.CoinSanityRange]))) + math.ceil(25 / World_Options.coinbundlequantity))) def set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world): - if World_Options[Options.CoinSanity] != Options.CoinSanity.option_none: + if World_Options.coinsanity != Options.CoinSanity.option_none: return add_rule(world.get_entrance("Boss Door", player), has_enough_coin_freemium(player, 889)) @@ -442,10 +442,10 @@ def set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, def set_completion_condition(World_Options, player, world): - if World_Options[Options.Campaign] == Options.Campaign.option_basic: + if World_Options.campaign == Options.Campaign.option_basic: world.completion_condition[player] = lambda state: state.has("Victory Basic", player) - if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die: + if World_Options.campaign == Options.Campaign.option_live_freemium_or_die: world.completion_condition[player] = lambda state: state.has("Victory Freemium", player) - if World_Options[Options.Campaign] == Options.Campaign.option_both: + if World_Options.campaign == Options.Campaign.option_both: world.completion_condition[player] = lambda state: state.has("Victory Basic", player) and state.has( "Victory Freemium", player) diff --git a/worlds/dlcquest/__init__.py b/worlds/dlcquest/__init__.py index 9569d0efcc1a..392eac7796fb 100644 --- a/worlds/dlcquest/__init__.py +++ b/worlds/dlcquest/__init__.py @@ -1,12 +1,13 @@ -from typing import Dict, Any, Iterable, Optional, Union +from typing import Union + from BaseClasses import Tutorial -from worlds.AutoWorld import World, WebWorld -from .Items import DLCQuestItem, item_table, ItemData, create_items -from .Locations import location_table, DLCQuestLocation -from .Options import DLCQuest_options, DLCQuestOptions, fetch_options -from .Rules import set_rules -from .Regions import create_regions +from worlds.AutoWorld import WebWorld, World from . import Options +from .Items import DLCQuestItem, ItemData, create_items, item_table +from .Locations import DLCQuestLocation, location_table +from .Options import DLCQuestOptions +from .Regions import create_regions +from .Rules import set_rules client_version = 0 @@ -35,10 +36,8 @@ class DLCqworld(World): data_version = 1 - option_definitions = DLCQuest_options - - def generate_early(self): - self.options = fetch_options(self.multiworld, self.player) + options_dataclass = DLCQuestOptions + options: DLCQuestOptions def create_regions(self): create_regions(self.multiworld, self.player, self.options) @@ -68,8 +67,8 @@ def create_items(self): self.multiworld.itempool.remove(item) def precollect_coinsanity(self): - if self.options[Options.Campaign] == Options.Campaign.option_basic: - if self.options[Options.CoinSanity] == Options.CoinSanity.option_coin and self.options[Options.CoinSanityRange] >= 5: + if self.options.campaign == Options.Campaign.option_basic: + if self.options.coinsanity == Options.CoinSanity.option_coin and self.options.coinbundlequantity >= 5: self.multiworld.push_precollected(self.create_item("Movement Pack")) @@ -80,12 +79,11 @@ def create_item(self, item: Union[str, ItemData]) -> DLCQuestItem: return DLCQuestItem(item.name, item.classification, item.code, self.player) def fill_slot_data(self): - return { - "death_link": self.multiworld.death_link[self.player].value, - "ending_choice": self.multiworld.ending_choice[self.player].value, - "campaign": self.multiworld.campaign[self.player].value, - "coinsanity": self.multiworld.coinsanity[self.player].value, - "coinbundlerange": self.multiworld.coinbundlequantity[self.player].value, - "item_shuffle": self.multiworld.item_shuffle[self.player].value, - "seed": self.multiworld.per_slot_randoms[self.player].randrange(99999999) - } + options_dict = self.options.as_dict( + "death_link", "ending_choice", "campaign", "coinsanity", "item_shuffle" + ) + options_dict.update({ + "coinbundlerange": self.options.coinbundlequantity.value, + "seed": self.random.randrange(99999999) + }) + return options_dict diff --git a/worlds/dlcquest/test/TestItemShuffle.py b/worlds/dlcquest/test/TestItemShuffle.py new file mode 100644 index 000000000000..bfe999246a50 --- /dev/null +++ b/worlds/dlcquest/test/TestItemShuffle.py @@ -0,0 +1,130 @@ +from . import DLCQuestTestBase +from .. import Options + +sword = "Sword" +gun = "Gun" +wooden_sword = "Wooden Sword" +pickaxe = "Pickaxe" +humble_bindle = "Humble Indie Bindle" +box_supplies = "Box of Various Supplies" +items = [sword, gun, wooden_sword, pickaxe, humble_bindle, box_supplies] + +important_pack = "Incredibly Important Pack" + + +class TestItemShuffle(DLCQuestTestBase): + options = {Options.ItemShuffle.internal_name: Options.ItemShuffle.option_shuffled, + Options.Campaign.internal_name: Options.Campaign.option_both} + + def test_items_in_pool(self): + item_names = {item.name for item in self.multiworld.get_items()} + for item in items: + with self.subTest(f"{item}"): + self.assertIn(item, item_names) + + def test_item_locations_in_pool(self): + location_names = {location.name for location in self.multiworld.get_locations()} + for item_location in items: + with self.subTest(f"{item_location}"): + self.assertIn(item_location, location_names) + + def test_sword_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(sword)) + movement_pack = self.multiworld.create_item("Movement Pack", self.player) + self.collect(movement_pack) + self.assertFalse(self.can_reach_location(sword)) + time_pack = self.multiworld.create_item("Time is Money Pack", self.player) + self.collect(time_pack) + self.assertTrue(self.can_reach_location(sword)) + + def test_gun_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(gun)) + movement_pack = self.multiworld.create_item("Movement Pack", self.player) + self.collect(movement_pack) + self.assertFalse(self.can_reach_location(gun)) + sword_item = self.multiworld.create_item(sword, self.player) + self.collect(sword_item) + self.assertFalse(self.can_reach_location(gun)) + gun_pack = self.multiworld.create_item("Gun Pack", self.player) + self.collect(gun_pack) + self.assertTrue(self.can_reach_location(gun)) + + def test_wooden_sword_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(wooden_sword)) + important_pack_item = self.multiworld.create_item(important_pack, self.player) + self.collect(important_pack_item) + self.assertTrue(self.can_reach_location(wooden_sword)) + + def test_bindle_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(humble_bindle)) + wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player) + self.collect(wooden_sword_item) + self.assertFalse(self.can_reach_location(humble_bindle)) + plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player) + self.collect(plants_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + wall_jump_pack = self.multiworld.create_item("Wall Jump Pack", self.player) + self.collect(wall_jump_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + name_change_pack = self.multiworld.create_item("Name Change Pack", self.player) + self.collect(name_change_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + cut_content_pack = self.multiworld.create_item("Cut Content Pack", self.player) + self.collect(cut_content_pack) + self.assertFalse(self.can_reach_location(humble_bindle)) + box_supplies_item = self.multiworld.create_item(box_supplies, self.player) + self.collect(box_supplies_item) + self.assertTrue(self.can_reach_location(humble_bindle)) + + def test_box_supplies_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(box_supplies)) + wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player) + self.collect(wooden_sword_item) + self.assertFalse(self.can_reach_location(box_supplies)) + plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player) + self.collect(plants_pack) + self.assertFalse(self.can_reach_location(box_supplies)) + wall_jump_pack = self.multiworld.create_item("Wall Jump Pack", self.player) + self.collect(wall_jump_pack) + self.assertFalse(self.can_reach_location(box_supplies)) + name_change_pack = self.multiworld.create_item("Name Change Pack", self.player) + self.collect(name_change_pack) + self.assertFalse(self.can_reach_location(box_supplies)) + cut_content_pack = self.multiworld.create_item("Cut Content Pack", self.player) + self.collect(cut_content_pack) + self.assertTrue(self.can_reach_location(box_supplies)) + + def test_pickaxe_location_has_correct_rules(self): + self.assertFalse(self.can_reach_location(pickaxe)) + wooden_sword_item = self.multiworld.create_item(wooden_sword, self.player) + self.collect(wooden_sword_item) + self.assertFalse(self.can_reach_location(pickaxe)) + plants_pack = self.multiworld.create_item("Harmless Plants Pack", self.player) + self.collect(plants_pack) + self.assertFalse(self.can_reach_location(pickaxe)) + wall_jump_pack = self.multiworld.create_item("Wall Jump Pack", self.player) + self.collect(wall_jump_pack) + self.assertFalse(self.can_reach_location(pickaxe)) + name_change_pack = self.multiworld.create_item("Name Change Pack", self.player) + self.collect(name_change_pack) + self.assertFalse(self.can_reach_location(pickaxe)) + bindle_item = self.multiworld.create_item("Humble Indie Bindle", self.player) + self.collect(bindle_item) + self.assertTrue(self.can_reach_location(pickaxe)) + + +class TestNoItemShuffle(DLCQuestTestBase): + options = {Options.ItemShuffle.internal_name: Options.ItemShuffle.option_disabled, + Options.Campaign.internal_name: Options.Campaign.option_both} + + def test_items_not_in_pool(self): + item_names = {item.name for item in self.multiworld.get_items()} + for item in items: + with self.subTest(f"{item}"): + self.assertNotIn(item, item_names) + + def test_item_locations_not_in_pool(self): + location_names = {location.name for location in self.multiworld.get_locations()} + for item_location in items: + with self.subTest(f"{item_location}"): + self.assertNotIn(item_location, location_names) \ No newline at end of file diff --git a/worlds/dlcquest/test/TestOptionsLong.py b/worlds/dlcquest/test/TestOptionsLong.py new file mode 100644 index 000000000000..d0a5c0ed7dfb --- /dev/null +++ b/worlds/dlcquest/test/TestOptionsLong.py @@ -0,0 +1,87 @@ +from typing import Dict + +from BaseClasses import MultiWorld +from Options import SpecialRange +from .option_names import options_to_include +from .checks.world_checks import assert_can_win, assert_same_number_items_locations +from . import DLCQuestTestBase, setup_dlc_quest_solo_multiworld +from ... import AutoWorldRegister + + +def basic_checks(tester: DLCQuestTestBase, multiworld: MultiWorld): + assert_can_win(tester, multiworld) + assert_same_number_items_locations(tester, multiworld) + + +def get_option_choices(option) -> Dict[str, int]: + if issubclass(option, SpecialRange): + return option.special_range_names + elif option.options: + return option.options + return {} + + +class TestGenerateDynamicOptions(DLCQuestTestBase): + def test_given_option_pair_when_generate_then_basic_checks(self): + num_options = len(options_to_include) + for option1_index in range(0, num_options): + for option2_index in range(option1_index + 1, num_options): + option1 = options_to_include[option1_index] + option2 = options_to_include[option2_index] + option1_choices = get_option_choices(option1) + option2_choices = get_option_choices(option2) + for key1 in option1_choices: + for key2 in option2_choices: + with self.subTest(f"{option1.internal_name}: {key1}, {option2.internal_name}: {key2}"): + choices = {option1.internal_name: option1_choices[key1], + option2.internal_name: option2_choices[key2]} + multiworld = setup_dlc_quest_solo_multiworld(choices) + basic_checks(self, multiworld) + + def test_given_option_truple_when_generate_then_basic_checks(self): + num_options = len(options_to_include) + for option1_index in range(0, num_options): + for option2_index in range(option1_index + 1, num_options): + for option3_index in range(option2_index + 1, num_options): + option1 = options_to_include[option1_index] + option2 = options_to_include[option2_index] + option3 = options_to_include[option3_index] + option1_choices = get_option_choices(option1) + option2_choices = get_option_choices(option2) + option3_choices = get_option_choices(option3) + for key1 in option1_choices: + for key2 in option2_choices: + for key3 in option3_choices: + with self.subTest(f"{option1.internal_name}: {key1}, {option2.internal_name}: {key2}, {option3.internal_name}: {key3}"): + choices = {option1.internal_name: option1_choices[key1], + option2.internal_name: option2_choices[key2], + option3.internal_name: option3_choices[key3]} + multiworld = setup_dlc_quest_solo_multiworld(choices) + basic_checks(self, multiworld) + + def test_given_option_quartet_when_generate_then_basic_checks(self): + num_options = len(options_to_include) + for option1_index in range(0, num_options): + for option2_index in range(option1_index + 1, num_options): + for option3_index in range(option2_index + 1, num_options): + for option4_index in range(option3_index + 1, num_options): + option1 = options_to_include[option1_index] + option2 = options_to_include[option2_index] + option3 = options_to_include[option3_index] + option4 = options_to_include[option4_index] + option1_choices = get_option_choices(option1) + option2_choices = get_option_choices(option2) + option3_choices = get_option_choices(option3) + option4_choices = get_option_choices(option4) + for key1 in option1_choices: + for key2 in option2_choices: + for key3 in option3_choices: + for key4 in option4_choices: + with self.subTest( + f"{option1.internal_name}: {key1}, {option2.internal_name}: {key2}, {option3.internal_name}: {key3}, {option4.internal_name}: {key4}"): + choices = {option1.internal_name: option1_choices[key1], + option2.internal_name: option2_choices[key2], + option3.internal_name: option3_choices[key3], + option4.internal_name: option4_choices[key4]} + multiworld = setup_dlc_quest_solo_multiworld(choices) + basic_checks(self, multiworld) diff --git a/worlds/dlcquest/test/__init__.py b/worlds/dlcquest/test/__init__.py new file mode 100644 index 000000000000..e998bd8a5e8b --- /dev/null +++ b/worlds/dlcquest/test/__init__.py @@ -0,0 +1,53 @@ +from typing import ClassVar + +from typing import Dict, FrozenSet, Tuple, Any +from argparse import Namespace + +from BaseClasses import MultiWorld +from test.TestBase import WorldTestBase +from .. import DLCqworld +from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld +from worlds.AutoWorld import call_all + + +class DLCQuestTestBase(WorldTestBase): + game = "DLCQuest" + world: DLCqworld + player: ClassVar[int] = 1 + + def world_setup(self, *args, **kwargs): + super().world_setup(*args, **kwargs) + if self.constructed: + self.world = self.multiworld.worlds[self.player] # noqa + + @property + def run_default_tests(self) -> bool: + # world_setup is overridden, so it'd always run default tests when importing DLCQuestTestBase + is_not_dlc_test = type(self) is not DLCQuestTestBase + should_run_default_tests = is_not_dlc_test and super().run_default_tests + return should_run_default_tests + + +def setup_dlc_quest_solo_multiworld(test_options=None, seed=None, _cache: Dict[FrozenSet[Tuple[str, Any]], MultiWorld] = {}) -> MultiWorld: #noqa + if test_options is None: + test_options = {} + + # Yes I reuse the worlds generated between tests, its speeds the execution by a couple seconds + frozen_options = frozenset(test_options.items()).union({seed}) + if frozen_options in _cache: + return _cache[frozen_options] + + multiworld = setup_base_solo_multiworld(DLCqworld, ()) + multiworld.set_seed(seed) + # print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test + args = Namespace() + for name, option in DLCqworld.options_dataclass.type_hints.items(): + value = option(test_options[name]) if name in test_options else option.from_any(option.default) + setattr(args, name, {1: value}) + multiworld.set_options(args) + for step in gen_steps: + call_all(multiworld, step) + + _cache[frozen_options] = multiworld + + return multiworld diff --git a/worlds/dlcquest/test/checks/__init__.py b/worlds/dlcquest/test/checks/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/worlds/dlcquest/test/checks/world_checks.py b/worlds/dlcquest/test/checks/world_checks.py new file mode 100644 index 000000000000..a97093d62036 --- /dev/null +++ b/worlds/dlcquest/test/checks/world_checks.py @@ -0,0 +1,42 @@ +from typing import List + +from BaseClasses import MultiWorld, ItemClassification +from .. import DLCQuestTestBase +from ... import Options + + +def get_all_item_names(multiworld: MultiWorld) -> List[str]: + return [item.name for item in multiworld.itempool] + + +def get_all_location_names(multiworld: MultiWorld) -> List[str]: + return [location.name for location in multiworld.get_locations() if not location.event] + + +def assert_victory_exists(tester: DLCQuestTestBase, multiworld: MultiWorld): + campaign = multiworld.campaign[1] + all_items = [item.name for item in multiworld.get_items()] + if campaign == Options.Campaign.option_basic or campaign == Options.Campaign.option_both: + tester.assertIn("Victory Basic", all_items) + if campaign == Options.Campaign.option_live_freemium_or_die or campaign == Options.Campaign.option_both: + tester.assertIn("Victory Freemium", all_items) + + +def collect_all_then_assert_can_win(tester: DLCQuestTestBase, multiworld: MultiWorld): + for item in multiworld.get_items(): + multiworld.state.collect(item) + campaign = multiworld.campaign[1] + if campaign == Options.Campaign.option_basic or campaign == Options.Campaign.option_both: + tester.assertTrue(multiworld.find_item("Victory Basic", 1).can_reach(multiworld.state)) + if campaign == Options.Campaign.option_live_freemium_or_die or campaign == Options.Campaign.option_both: + tester.assertTrue(multiworld.find_item("Victory Freemium", 1).can_reach(multiworld.state)) + + +def assert_can_win(tester: DLCQuestTestBase, multiworld: MultiWorld): + assert_victory_exists(tester, multiworld) + collect_all_then_assert_can_win(tester, multiworld) + + +def assert_same_number_items_locations(tester: DLCQuestTestBase, multiworld: MultiWorld): + non_event_locations = [location for location in multiworld.get_locations() if not location.event] + tester.assertEqual(len(multiworld.itempool), len(non_event_locations)) \ No newline at end of file diff --git a/worlds/dlcquest/test/option_names.py b/worlds/dlcquest/test/option_names.py new file mode 100644 index 000000000000..4a4b46e906cb --- /dev/null +++ b/worlds/dlcquest/test/option_names.py @@ -0,0 +1,5 @@ +from .. import DLCqworld + +options_to_exclude = ["progression_balancing", "accessibility", "start_inventory", "start_hints", "death_link"] +options_to_include = [option for option_name, option in DLCqworld.options_dataclass.type_hints.items() + if option_name not in options_to_exclude] diff --git a/worlds/doom_1993/Locations.py b/worlds/doom_1993/Locations.py index 942c7d2a42d4..778efb4661a8 100644 --- a/worlds/doom_1993/Locations.py +++ b/worlds/doom_1993/Locations.py @@ -1794,13 +1794,13 @@ class LocationDict(TypedDict, total=False): 'map': 7, 'index': 65, 'doom_type': 2004, - 'region': "Limbo (E3M7) Red"}, + 'region': "Limbo (E3M7) Green"}, 351297: {'name': 'Limbo (E3M7) - Armor', 'episode': 3, 'map': 7, 'index': 67, 'doom_type': 2018, - 'region': "Limbo (E3M7) Red"}, + 'region': "Limbo (E3M7) Green"}, 351298: {'name': 'Limbo (E3M7) - Yellow skull key', 'episode': 3, 'map': 7, @@ -2496,19 +2496,19 @@ class LocationDict(TypedDict, total=False): 'map': 6, 'index': 77, 'doom_type': 38, - 'region': "Against Thee Wickedly (E4M6) Yellow"}, + 'region': "Against Thee Wickedly (E4M6) Magenta"}, 351414: {'name': 'Against Thee Wickedly (E4M6) - Invulnerability', 'episode': 4, 'map': 6, 'index': 78, 'doom_type': 2022, - 'region': "Against Thee Wickedly (E4M6) Red"}, + 'region': "Against Thee Wickedly (E4M6) Pink"}, 351415: {'name': 'Against Thee Wickedly (E4M6) - Invulnerability 2', 'episode': 4, 'map': 6, 'index': 89, 'doom_type': 2022, - 'region': "Against Thee Wickedly (E4M6) Red"}, + 'region': "Against Thee Wickedly (E4M6) Magenta"}, 351416: {'name': 'Against Thee Wickedly (E4M6) - BFG9000', 'episode': 4, 'map': 6, @@ -2520,7 +2520,7 @@ class LocationDict(TypedDict, total=False): 'map': 6, 'index': 102, 'doom_type': 8, - 'region': "Against Thee Wickedly (E4M6) Red"}, + 'region': "Against Thee Wickedly (E4M6) Pink"}, 351418: {'name': 'Against Thee Wickedly (E4M6) - Berserk', 'episode': 4, 'map': 6, @@ -2550,7 +2550,7 @@ class LocationDict(TypedDict, total=False): 'map': 6, 'index': -1, 'doom_type': -1, - 'region': "Against Thee Wickedly (E4M6) Red"}, + 'region': "Against Thee Wickedly (E4M6) Magenta"}, 351423: {'name': 'And Hell Followed (E4M7) - Shotgun', 'episode': 4, 'map': 7, @@ -2628,7 +2628,7 @@ class LocationDict(TypedDict, total=False): 'map': 7, 'index': 182, 'doom_type': 39, - 'region': "And Hell Followed (E4M7) Main"}, + 'region': "And Hell Followed (E4M7) Blue"}, 351436: {'name': 'And Hell Followed (E4M7) - Red skull key', 'episode': 4, 'map': 7, @@ -3414,6 +3414,7 @@ class LocationDict(TypedDict, total=False): "Command Control (E1M4) - Supercharge", "Command Control (E1M4) - Mega Armor", "Containment Area (E2M2) - Supercharge", + "Containment Area (E2M2) - Plasma gun", "Pandemonium (E3M3) - Mega Armor", "House of Pain (E3M4) - Chaingun", "House of Pain (E3M4) - Invulnerability", diff --git a/worlds/doom_1993/Regions.py b/worlds/doom_1993/Regions.py index 58626e62ae25..602c29f5bd83 100644 --- a/worlds/doom_1993/Regions.py +++ b/worlds/doom_1993/Regions.py @@ -394,7 +394,8 @@ class RegionDict(TypedDict, total=False): "episode":3, "connections":[ "Limbo (E3M7) Red", - "Limbo (E3M7) Blue"]}, + "Limbo (E3M7) Blue", + "Limbo (E3M7) Pink"]}, {"name":"Limbo (E3M7) Blue", "connects_to_hub":False, "episode":3, @@ -404,11 +405,24 @@ class RegionDict(TypedDict, total=False): "episode":3, "connections":[ "Limbo (E3M7) Main", - "Limbo (E3M7) Yellow"]}, + "Limbo (E3M7) Yellow", + "Limbo (E3M7) Green"]}, {"name":"Limbo (E3M7) Yellow", "connects_to_hub":False, "episode":3, "connections":["Limbo (E3M7) Red"]}, + {"name":"Limbo (E3M7) Pink", + "connects_to_hub":False, + "episode":3, + "connections":[ + "Limbo (E3M7) Green", + "Limbo (E3M7) Main"]}, + {"name":"Limbo (E3M7) Green", + "connects_to_hub":False, + "episode":3, + "connections":[ + "Limbo (E3M7) Pink", + "Limbo (E3M7) Red"]}, # Dis (E3M8) {"name":"Dis (E3M8) Main", @@ -529,19 +543,32 @@ class RegionDict(TypedDict, total=False): {"name":"Against Thee Wickedly (E4M6) Main", "connects_to_hub":True, "episode":4, + "connections":["Against Thee Wickedly (E4M6) Blue"]}, + {"name":"Against Thee Wickedly (E4M6) Red", + "connects_to_hub":False, + "episode":4, "connections":[ "Against Thee Wickedly (E4M6) Blue", + "Against Thee Wickedly (E4M6) Pink", + "Against Thee Wickedly (E4M6) Main"]}, + {"name":"Against Thee Wickedly (E4M6) Blue", + "connects_to_hub":False, + "episode":4, + "connections":[ + "Against Thee Wickedly (E4M6) Main", "Against Thee Wickedly (E4M6) Yellow", "Against Thee Wickedly (E4M6) Red"]}, - {"name":"Against Thee Wickedly (E4M6) Red", + {"name":"Against Thee Wickedly (E4M6) Magenta", "connects_to_hub":False, "episode":4, "connections":["Against Thee Wickedly (E4M6) Main"]}, - {"name":"Against Thee Wickedly (E4M6) Blue", + {"name":"Against Thee Wickedly (E4M6) Yellow", "connects_to_hub":False, "episode":4, - "connections":["Against Thee Wickedly (E4M6) Main"]}, - {"name":"Against Thee Wickedly (E4M6) Yellow", + "connections":[ + "Against Thee Wickedly (E4M6) Blue", + "Against Thee Wickedly (E4M6) Magenta"]}, + {"name":"Against Thee Wickedly (E4M6) Pink", "connects_to_hub":False, "episode":4, "connections":["Against Thee Wickedly (E4M6) Main"]}, diff --git a/worlds/doom_1993/Rules.py b/worlds/doom_1993/Rules.py index 6f24112cbefa..6e13a8af34ce 100644 --- a/worlds/doom_1993/Rules.py +++ b/worlds/doom_1993/Rules.py @@ -50,9 +50,6 @@ def set_episode1_rules(player, world): set_rule(world.get_entrance("Command Control (E1M4) Blue -> Command Control (E1M4) Main", player), lambda state: state.has("Command Control (E1M4) - Yellow keycard", player, 1) or state.has("Command Control (E1M4) - Blue keycard", player, 1)) - set_rule(world.get_entrance("Command Control (E1M4) Yellow -> Command Control (E1M4) Main", player), lambda state: - state.has("Command Control (E1M4) - Yellow keycard", player, 1) or - state.has("Command Control (E1M4) - Blue keycard", player, 1)) # Phobos Lab (E1M5) set_rule(world.get_entrance("Hub -> Phobos Lab (E1M5) Main", player), lambda state: @@ -354,8 +351,12 @@ def set_episode3_rules(player, world): state.has("Limbo (E3M7) - Red skull key", player, 1)) set_rule(world.get_entrance("Limbo (E3M7) Main -> Limbo (E3M7) Blue", player), lambda state: state.has("Limbo (E3M7) - Blue skull key", player, 1)) + set_rule(world.get_entrance("Limbo (E3M7) Main -> Limbo (E3M7) Pink", player), lambda state: + state.has("Limbo (E3M7) - Blue skull key", player, 1)) set_rule(world.get_entrance("Limbo (E3M7) Red -> Limbo (E3M7) Yellow", player), lambda state: state.has("Limbo (E3M7) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("Limbo (E3M7) Pink -> Limbo (E3M7) Green", player), lambda state: + state.has("Limbo (E3M7) - Red skull key", player, 1)) # Dis (E3M8) set_rule(world.get_entrance("Hub -> Dis (E3M8) Main", player), lambda state: @@ -466,12 +467,10 @@ def set_episode4_rules(player, world): state.has("BFG9000", player, 1))) set_rule(world.get_entrance("Against Thee Wickedly (E4M6) Main -> Against Thee Wickedly (E4M6) Blue", player), lambda state: state.has("Against Thee Wickedly (E4M6) - Blue skull key", player, 1)) - set_rule(world.get_entrance("Against Thee Wickedly (E4M6) Main -> Against Thee Wickedly (E4M6) Yellow", player), lambda state: + set_rule(world.get_entrance("Against Thee Wickedly (E4M6) Blue -> Against Thee Wickedly (E4M6) Yellow", player), lambda state: state.has("Against Thee Wickedly (E4M6) - Yellow skull key", player, 1)) - set_rule(world.get_entrance("Against Thee Wickedly (E4M6) Main -> Against Thee Wickedly (E4M6) Red", player), lambda state: + set_rule(world.get_entrance("Against Thee Wickedly (E4M6) Blue -> Against Thee Wickedly (E4M6) Red", player), lambda state: state.has("Against Thee Wickedly (E4M6) - Red skull key", player, 1)) - set_rule(world.get_entrance("Against Thee Wickedly (E4M6) Blue -> Against Thee Wickedly (E4M6) Main", player), lambda state: - state.has("Against Thee Wickedly (E4M6) - Blue skull key", player, 1)) # And Hell Followed (E4M7) set_rule(world.get_entrance("Hub -> And Hell Followed (E4M7) Main", player), lambda state: diff --git a/worlds/doom_1993/docs/setup_en.md b/worlds/doom_1993/docs/setup_en.md index 5afe6d3e26e4..cfd97f623a0c 100644 --- a/worlds/doom_1993/docs/setup_en.md +++ b/worlds/doom_1993/docs/setup_en.md @@ -11,9 +11,9 @@ ## Installing AP Doom 1. Download [APDOOM.zip](https://github.com/Daivuk/apdoom/releases) and extract it. -2. Copy DOOM.WAD from your steam install into the extracted folder. +2. Copy `DOOM.WAD` from your game's installation directory into the newly extracted folder. You can find the folder in steam by finding the game in your library, - right clicking it and choosing *Manage→Browse Local Files*. + right-clicking it and choosing **Manage -> Browse Local Files**. ## Joining a MultiWorld Game diff --git a/worlds/factorio/Client.py b/worlds/factorio/Client.py index 58dbb6df83dd..050455bb076a 100644 --- a/worlds/factorio/Client.py +++ b/worlds/factorio/Client.py @@ -446,6 +446,10 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: logger.warning("It appears your mods are loaded from Appdata, " "this can lead to problems with multiple Factorio instances. " "If this is the case, you will get a file locked error running Factorio.") + elif "Couldn't create lock file" in msg: + raise Exception(f"This Factorio (at {executable}) is either already running, " + "or a Factorio sharing data directories is already running. " + "Server could not start up.") if not rcon_client and "Starting RCON interface at IP ADDR:" in msg: rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) if ctx.mod_version == ctx.__class__.mod_version: diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 0331c2d013ea..18eee67e036f 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -146,8 +146,8 @@ class TechTreeLayout(Choice): class TechTreeInformation(Choice): """How much information should be displayed in the tech tree. - None: No indication what a research unlocks - Advancement: Indicators which researches unlock items that are considered logical advancements + None: No indication of what a research unlocks. + Advancement: Indicates if a research unlocks an item that is considered logical advancement, but not who it is for. Full: Labels with exact names and recipients of unlocked items; all researches are prefilled into the !hint command. """ display_name = "Technology Tree Information" @@ -390,8 +390,8 @@ class FactorioWorldGen(OptionDict): def __init__(self, value: typing.Dict[str, typing.Any]): advanced = {"pollution", "enemy_evolution", "enemy_expansion"} self.value = { - "basic": {key: value[key] for key in value.keys() - advanced}, - "advanced": {key: value[key] for key in value.keys() & advanced} + "basic": {k: v for k, v in value.items() if k not in advanced}, + "advanced": {k: v for k, v in value.items() if k in advanced} } # verify min_values <= max_values diff --git a/worlds/factorio/Technologies.py b/worlds/factorio/Technologies.py index d68c6f2f779e..096396c0e774 100644 --- a/worlds/factorio/Technologies.py +++ b/worlds/factorio/Technologies.py @@ -1,6 +1,6 @@ from __future__ import annotations -import json +import orjson import logging import os import string @@ -20,7 +20,7 @@ def load_json_data(data_name: str) -> Union[List[str], Dict[str, Any]]: - return json.loads(pkgutil.get_data(__name__, "data/" + data_name + ".json").decode()) + return orjson.loads(pkgutil.get_data(__name__, "data/" + data_name + ".json")) techs_future = pool.submit(load_json_data, "techs") diff --git a/worlds/factorio/docs/en_Factorio.md b/worlds/factorio/docs/en_Factorio.md index 61bceb3820a1..dbc33d05dfde 100644 --- a/worlds/factorio/docs/en_Factorio.md +++ b/worlds/factorio/docs/en_Factorio.md @@ -42,3 +42,9 @@ depositing excess energy and supplementing energy deficits, much like Accumulato Each placed EnergyLink Bridge provides 10 MW of throughput. The shared storage has unlimited capacity, but 25% of energy is lost during depositing. The amount of energy currently in the shared storage is displayed in the Archipelago client. It can also be queried by typing `/energy-link` in-game. + +## Unique Local Commands +The following commands are only available when using the FactorioClient to play Factorio with Archipelago. + +- `/factorio ` Sends the command argument to the Factorio server as a command. +- `/energy-link` Displays the amount of energy currently in shared storage for EnergyLink diff --git a/worlds/factorio/docs/setup_en.md b/worlds/factorio/docs/setup_en.md index 09ad431a21cc..b6d45459253a 100644 --- a/worlds/factorio/docs/setup_en.md +++ b/worlds/factorio/docs/setup_en.md @@ -31,7 +31,7 @@ them. Factorio player settings page: [Factorio Settings Page](/games/Factorio/pl ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML -Validator page: [Yaml Validation Page](/mysterycheck) +Validator page: [Yaml Validation Page](/check) ## Connecting to Someone Else's Factorio Game diff --git a/worlds/factorio/requirements.txt b/worlds/factorio/requirements.txt index c45fb771da6a..8fb74e933045 100644 --- a/worlds/factorio/requirements.txt +++ b/worlds/factorio/requirements.txt @@ -1 +1,2 @@ factorio-rcon-py>=2.0.1 +orjson>=3.9.7 diff --git a/worlds/ff1/__init__.py b/worlds/ff1/__init__.py index 56b41d62d042..432467399ea6 100644 --- a/worlds/ff1/__init__.py +++ b/worlds/ff1/__init__.py @@ -91,7 +91,7 @@ def create_item(self, name: str) -> Item: def set_rules(self): self.multiworld.completion_condition[self.player] = lambda state: state.has(CHAOS_TERMINATED_EVENT, self.player) - def generate_basic(self): + def create_items(self): items = get_options(self.multiworld, 'items', self.player) if FF1_BRIDGE in items.keys(): self._place_locked_item_in_sphere0(FF1_BRIDGE) diff --git a/worlds/ff1/docs/en_Final Fantasy.md b/worlds/ff1/docs/en_Final Fantasy.md index a62b5ec12605..89629197434f 100644 --- a/worlds/ff1/docs/en_Final Fantasy.md +++ b/worlds/ff1/docs/en_Final Fantasy.md @@ -8,19 +8,24 @@ website: [FF1R Website](https://finalfantasyrandomizer.com/) ## What does randomization do to this game? -A better questions is what isn't randomized at this point. Enemies stats and spell, character spells, shop inventory and -boss stats and spells are all commonly randomized. Unlike most other randomizers it is also most standard to shuffle -progression items and non-progression items into separate pools and then redistribute them to their respective -locations. So, for example, Princess Sarah may have the CANOE instead of the LUTE; however, she will never have a Heal -Pot or some armor. There are plenty of other things that can be randomized on the main randomizer -site: [FF1R Website](https://finalfantasyrandomizer.com/) +Enemy stats and spell, boss stats and spells, character spells, and shop inventories are all commonly randomized. Unlike +most other randomizers, it is standard to shuffle progression items and non-progression items into separate pools +and then redistribute them to their respective locations. For example, Princess Sarah may have the CANOE instead +of the LUTE; however, she will never have a Heal Pot or armor. + +Plenty of other things to be randomized can be found on the main randomizer site: +[FF1R Website](https://finalfantasyrandomizer.com/) ## What Final Fantasy items can appear in other players' worlds? -All items can appear in other players worlds. This includes consumables, shards, weapons, armor and, of course, key -items. +All items can appear in other players worlds, including consumables, shards, weapons, armor, and key items. ## What does another world's item look like in Final Fantasy -All local and remote items appear the same. It will say that you received an item and then BOTH the client log and the +All local and remote items appear the same. Final Fantasy will say that you received an item, then BOTH the client log and the emulator will display what was found external to the in-game text box. + +## Unique Local Commands +The following command is only available when using the FF1Client for the Final Fantasy Randomizer. + +- `/nes` Shows the current status of the NES connection. diff --git a/worlds/ff1/docs/multiworld_en.md b/worlds/ff1/docs/multiworld_en.md index 51fcd9b7bfc4..d3dc457f01be 100644 --- a/worlds/ff1/docs/multiworld_en.md +++ b/worlds/ff1/docs/multiworld_en.md @@ -32,14 +32,14 @@ Generate a game by going to the site and performing the following steps: prefer, or it is your first time we suggest starting with the 'Shard Hunt' preset (which requires you to collect a number of shards to go to the end dungeon) or the 'Beginner' preset if you prefer to kill the original fiends. 2. Go to the `Goal` tab and ensure `Archipelago` is enabled. Set your player name to any name that represents you. -3. Upload you `Final Fantasy(USA).nes` (and click `Remember ROM` for the future!) +3. Upload your `Final Fantasy(USA).nes` (and click `Remember ROM` for the future!) 4. Press the `NEW` button beside `Seed` a few times 5. Click `GENERATE ROM` -It should download two files. One is the `*.nes` file which your emulator will run and the other is the yaml file +It should download two files. One is the `*.nes` file which your emulator will run, and the other is the yaml file required by Archipelago.gg -At this point you are ready to join the multiworld. If you are uncertain on how to generate, host or join a multiworld +At this point, you are ready to join the multiworld. If you are uncertain on how to generate, host, or join a multiworld, please refer to the [game agnostic setup guide](/tutorial/Archipelago/setup/en). ## Running the Client Program and Connecting to the Server @@ -67,7 +67,7 @@ Once the Archipelago server has been hosted: ## Play the game -When the client shows both NES and server are connected you are good to go. You can check the connection status of the +When the client shows both NES and server are connected, you are good to go. You can check the connection status of the NES at any time by running `/nes` ### Other Client Commands diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 456795dac4a7..6d5e20462f13 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -108,7 +108,9 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) * `minimal` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest in a dungeon in ALTTP making it impossible to get and finish the dungeon. -* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. +* `progression_balancing` is a system the Archipelago generator uses to try and reduce + ["BK mode"](/glossary/en/#burger-king-/-bk-mode) + as much as possible. This primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that players almost always have something to do. This can be in a range from 0 to 99, and is 50 by default. This number represents a percentage of the furthest progressible player. @@ -130,7 +132,7 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) there without using any hint points. * `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk" item which isn't necessary for progression to go in these locations. -* `priority_locations` is the inverse of `exlcude_locations`, forcing a progression item in the defined locations. +* `priority_locations` is the inverse of `exclude_locations`, forcing a progression item in the defined locations. * `item_links` allows players to link their items into a group with the same item link name and game. The items declared in `item_pool` get combined and when an item is found for the group, all players in the group receive it. Item links can also have local and non local items, forcing the items to either be placed within the worlds of the group or in diff --git a/worlds/generic/docs/commands_en.md b/worlds/generic/docs/commands_en.md index e52ea20fd24d..3e7c0bd4bd30 100644 --- a/worlds/generic/docs/commands_en.md +++ b/worlds/generic/docs/commands_en.md @@ -1,96 +1,108 @@ -### Helpful Commands +# Helpful Commands Commands are split into two types: client commands and server commands. Client commands are commands which are executed by the client and do not affect the Archipelago remote session. Server commands are commands which are executed by the Archipelago server and affect the Archipelago session or otherwise provide feedback from the server. -In clients which have their own commands the commands are typically prepended by a forward slash:`/`. Remote commands -are always submitted to the server prepended with an exclamation point: `!`. +In clients which have their own commands the commands are typically prepended by a forward slash: `/`. -#### Local Commands +Server commands are always submitted to the server prepended with an exclamation point: `!`.
    -The following list is a list of client commands which may be available to you through your Archipelago client. You -execute these commands in your client window. - -The following commands are available in these clients: SNIClient, FactorioClient, FF1Client. - -- `/connect ` Connect to the multiworld server. -- `/disconnect` Disconnects you from your current session. -- `/received` Displays all the items you have found or been sent. -- `/missing` Displays all the locations along with their current status (checked/missing). -- `/items` Lists all the item names for the current game. -- `/locations` Lists all the location names for the current game. -- `/ready` Sends ready status to the server. -- `/help` Returns a list of available commands. -- `/license` Returns the software licensing information. -- Just typing anything will broadcast a message to all players - -##### FF1Client Only - -The following command is only available when using the FF1Client for the Final Fantasy Randomizer. - -- `/nes` Shows the current status of the NES connection. - -##### SNIClient Only - -The following command is only available when using the SNIClient for SNES based games. +# Server Commands -- `/snes` Attempts to connect to your SNES device via SNI. -- `/snes_close` Closes the current SNES connection. -- `/slow_mode` Toggles on or off slow mode, which limits the rate in which you receive items. - -##### FactorioClient Only - -The following command is only available when using the FactorioClient to play Factorio with Archipelago. - -- `/factorio ` Sends the command argument to the Factorio server as a command. - -#### Remote Commands - -Remote commands may be executed by any client which allows for sending text chat to the Archipelago server. If your +Server commands may be executed by any client which allows for sending text chat to the Archipelago server. If your client does not allow for sending chat then you may connect to your game slot with the TextClient which comes with the Archipelago installation. In order to execute the command you need to merely send a text message with the command, including the exclamation point. -- `!help` Returns a listing of available remote commands. +### General +- `!help` Returns a listing of available commands. - `!license` Returns the software licensing information. -- `!countdown ` Starts a countdown using the given seconds value. Useful for synchronizing starts. - Defaults to 10 seconds if no argument is provided. - `!options` Returns the current server options, including password in plaintext. +- `!players` Returns info about the currently connected and non-connected players. +- `!status` Returns information about the connection status and check completion numbers for all players in the current room.
    (Optionally mention a Tag name and get information on who has that Tag. For example: !status DeathLink) + + +### Utilities +- `!countdown ` Starts a countdown using the given seconds value. Useful for synchronizing starts. + Defaults to 10 seconds if no argument is provided. +- `!alias ` Sets your alias, which allows you to use commands with the alias rather than your provided name. - `!admin ` Executes a command as if you typed it into the server console. Remote administration must be enabled. -- `!players` Returns info about the currently connected and non-connected players. -- `!status` Returns information about your team. (Currently all players as teams are unimplemented.) + +### Information - `!remaining` Lists the items remaining in your game, but not where they are or who they go to. - `!missing` Lists the location checks you are missing from the server's perspective. - `!checked` Lists all the location checks you've done from the server's perspective. -- `!alias ` Sets your alias. -- `!getitem ` Cheats an item, if it is enabled in the server. -- `!hint_location ` Hints for a location specifically. Useful in games where item names may match location - names such as Factorio. -- `!hint ` Tells you at which location in whose game your Item is. Note you need to have checked some - locations to earn a hint. You can check how many you have by just running `!hint` -- `!release` If you didn't turn on auto-release or if you allowed releasing prior to goal completion. Remember that " - releasing" actually means sending out your remaining items in your world. -- `!collect` Grants you all the remaining checks in your world. Typically used after goal completion. - -#### Host only (on Archipelago.gg or in your server console) +### Hints +- `!hint` Lists all hints relevant to your world, the number of points you have for hints, and how much a hint costs. +- `!hint ` Tells you the game world and location your item is in, uses points earned from completing locations. +- `!hint_location ` Tells you what item is in a specific location, uses points earned from completing locations. + +### Collect/Release +- `!collect` Grants you all the remaining items for your world by collecting them from all games. Typically used after +goal completion. +- `!release` Releases all items contained in your world to other worlds. Typically, done automatically by the sever, but +can be configured to allow/require manual usage of this command. + +### Cheats +- `!getitem ` Cheats an item to the currently connected slot, if it is enabled in the server. + + +## Host only (on Archipelago.gg or in your server console) + +### General - `/help` Returns a list of commands available in the console. - `/license` Returns the software licensing information. -- `/countdown ` Starts a countdown which is sent to all players via text chat. Defaults to 10 seconds if no - argument is provided. - `/options` Lists the server's current options, including password in plaintext. -- `/save` Saves the state of the current multiworld. Note that the server autosaves on a minute basis. - `/players` List currently connected players. +- `/save` Saves the state of the current multiworld. Note that the server auto-saves on a minute basis. - `/exit` Shutdown the server -- `/alias ` Assign a player an alias. + +### Utilities +- `/countdown ` Starts a countdown sent to all players via text chat. Defaults to 10 seconds if no + argument is provided. +- `/option