diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index d24c55b49a..1a76a7f471 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 8e4cc86657..022abe38fe 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,14 @@ *.apmc *.apz5 *.aptloz +*.apemerald *.pyc *.pyd *.sfc *.z64 *.n64 *.nes +*.smc *.sms *.gb *.gbc @@ -27,16 +29,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 +145,7 @@ ipython_config.py .venv* env/ venv/ +/venv*/ ENV/ env.bak/ venv.bak/ diff --git a/AdventureClient.py b/AdventureClient.py index d2f4e734ac..06e4d60dad 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -115,11 +115,12 @@ class AdventureContext(CommonContext): msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" self._set_message(msg, SYSTEM_MESSAGE_ID) elif cmd == "Retrieved": - self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] - if self.freeincarnates_used is None: - self.freeincarnates_used = 0 - self.freeincarnates_used += self.freeincarnate_pending - self.send_pending_freeincarnates() + if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: + self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() elif cmd == "SetReply": if args["key"] == f"adventure_{self.auth}_freeincarnates_used": self.freeincarnates_used = args["value"] diff --git a/BaseClasses.py b/BaseClasses.py index 26cdfb5285..855e69c5d4 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1,13 +1,15 @@ from __future__ import annotations import copy +import itertools import functools import logging import random import secrets 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 import Counter, deque +from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ Type, ClassVar @@ -46,7 +48,6 @@ class ThreadBarrierProxy: class MultiWorld(): debug_types = False player_name: Dict[int, str] - _region_cache: Dict[int, Dict[str, Region]] difficulty_requirements: dict required_medallions: dict dark_room_logic: Dict[int, str] @@ -56,7 +57,7 @@ class MultiWorld(): plando_connections: List worlds: Dict[int, auto_world] groups: Dict[int, Group] - regions: List[Region] + regions: RegionManager itempool: List[Item] is_race: bool = False precollected_items: Dict[int, List[Item]] @@ -91,6 +92,39 @@ class MultiWorld(): def __getitem__(self, player) -> bool: return self.rule(player) + class RegionManager: + region_cache: Dict[int, Dict[str, Region]] + entrance_cache: Dict[int, Dict[str, Entrance]] + location_cache: Dict[int, Dict[str, Location]] + + def __init__(self, players: int): + self.region_cache = {player: {} for player in range(1, players+1)} + self.entrance_cache = {player: {} for player in range(1, players+1)} + self.location_cache = {player: {} for player in range(1, players+1)} + + def __iadd__(self, other: Iterable[Region]): + self.extend(other) + return self + + def append(self, region: Region): + self.region_cache[region.player][region.name] = region + + def extend(self, regions: Iterable[Region]): + for region in regions: + self.region_cache[region.player][region.name] = region + + def add_group(self, new_id: int): + self.region_cache[new_id] = {} + self.entrance_cache[new_id] = {} + self.location_cache[new_id] = {} + + def __iter__(self) -> Iterator[Region]: + for regions in self.region_cache.values(): + yield from regions.values() + + def __len__(self): + return sum(len(regions) for regions in self.region_cache.values()) + def __init__(self, players: int): # world-local random state is saved for multiple generations running concurrently self.random = ThreadBarrierProxy(random.Random()) @@ -99,16 +133,12 @@ class MultiWorld(): self.glitch_triforce = False self.algorithm = 'balanced' self.groups = {} - self.regions = [] + self.regions = self.RegionManager(players) self.shops = [] self.itempool = [] self.seed = None self.seed_name: str = "Unavailable" self.precollected_items = {player: [] for player in self.player_ids} - self._cached_entrances = None - self._cached_locations = None - self._entrance_cache = {} - self._location_cache: Dict[Tuple[str, int], Location] = {} self.required_locations = [] self.light_world_light_cone = False self.dark_world_light_cone = False @@ -136,7 +166,6 @@ class MultiWorld(): def set_player_attr(attr, val): self.__dict__.setdefault(attr, {})[player] = val - set_player_attr('_region_cache', {}) set_player_attr('shuffle', "vanilla") set_player_attr('logic', "noglitches") set_player_attr('mode', 'open') @@ -180,7 +209,6 @@ class MultiWorld(): set_player_attr('plando_connections', []) set_player_attr('game', "A Link to the Past") set_player_attr('completion_condition', lambda state: True) - self.custom_data = {} self.worlds = {} self.per_slot_randoms = {} self.plando_options = PlandoOptions.none @@ -197,19 +225,11 @@ class MultiWorld(): return group_id, group new_id: int = self.players + len(self.groups) + 1 + self.regions.add_group(new_id) self.game[new_id] = game - self.custom_data[new_id] = {} 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 +252,28 @@ class MultiWorld(): 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, {})) + # TODO - remove this section once all worlds use options dataclasses + all_keys: Set[str] = {key for player in self.player_ids for key in + AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints} + for option_key in all_keys: + option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. " + f"Please use `self.options.{option_key}` instead.") + option.update(getattr(args, option_key, {})) + setattr(self, option_key, option) 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] + options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.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 +328,6 @@ class MultiWorld(): 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 @@ -321,11 +336,15 @@ class MultiWorld(): def player_ids(self) -> Tuple[int, ...]: return tuple(range(1, self.players + 1)) - @functools.lru_cache() + @Utils.cache_self1 def get_game_players(self, game_name: str) -> Tuple[int, ...]: return tuple(player for player in self.player_ids if self.game[player] == game_name) - @functools.lru_cache() + @Utils.cache_self1 + def get_game_groups(self, game_name: str) -> Tuple[int, ...]: + return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name) + + @Utils.cache_self1 def get_game_worlds(self, game_name: str): return tuple(world for player, world in self.worlds.items() if player not in self.groups and self.game[player] == game_name) @@ -343,50 +362,21 @@ class MultiWorld(): """ the base name (without file extension) for each player's output file for a seed """ return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}" - def initialize_regions(self, regions=None): - for region in regions if regions else self.regions: - region.multiworld = self - self._region_cache[region.player][region.name] = region - @functools.cached_property def world_name_lookup(self): return {self.player_name[player_id]: player_id for player_id in self.player_ids} - def _recache(self): - """Rebuild world cache""" - self._cached_locations = None - for region in self.regions: - player = region.player - self._region_cache[player][region.name] = region - for exit in region.exits: - self._entrance_cache[exit.name, player] = exit + def get_regions(self, player: Optional[int] = None) -> Collection[Region]: + return self.regions if player is None else self.regions.region_cache[player].values() - for r_location in region.locations: - self._location_cache[r_location.name, player] = r_location + def get_region(self, region_name: str, player: int) -> Region: + return self.regions.region_cache[player][region_name] - def get_regions(self, player=None): - return self.regions if player is None else self._region_cache[player].values() + def get_entrance(self, entrance_name: str, player: int) -> Entrance: + return self.regions.entrance_cache[player][entrance_name] - def get_region(self, regionname: str, player: int) -> Region: - try: - return self._region_cache[player][regionname] - except KeyError: - self._recache() - return self._region_cache[player][regionname] - - def get_entrance(self, entrance: str, player: int) -> Entrance: - try: - return self._entrance_cache[entrance, player] - except KeyError: - self._recache() - return self._entrance_cache[entrance, player] - - def get_location(self, location: str, player: int) -> Location: - try: - return self._location_cache[location, player] - except KeyError: - self._recache() - return self._location_cache[location, player] + def get_location(self, location_name: str, player: int) -> Location: + return self.regions.location_cache[player][location_name] def get_all_state(self, use_cache: bool) -> CollectionState: cached = getattr(self, "_all_state", None) @@ -447,28 +437,22 @@ class MultiWorld(): logging.debug('Placed %s at %s', item, location) - def get_entrances(self) -> List[Entrance]: - if self._cached_entrances is None: - self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances] - return self._cached_entrances - - def clear_entrance_cache(self): - self._cached_entrances = None + def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]: + if player is not None: + return self.regions.entrance_cache[player].values() + return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values() + for player in self.regions.entrance_cache)) def register_indirect_condition(self, region: Region, entrance: Entrance): """Report that access to this Region can result in unlocking this Entrance, state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic.""" self.indirect_connections.setdefault(region, set()).add(entrance) - def get_locations(self, player: Optional[int] = None) -> List[Location]: - if self._cached_locations is None: - self._cached_locations = [location for region in self.regions for location in region.locations] + def get_locations(self, player: Optional[int] = None) -> Iterable[Location]: if player is not None: - return [location for location in self._cached_locations if location.player == player] - return self._cached_locations - - def clear_location_cache(self): - self._cached_locations = None + return self.regions.location_cache[player].values() + return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values() + for player in self.regions.location_cache)) def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]: return [location for location in self.get_locations(player) if location.item is None] @@ -490,16 +474,17 @@ class MultiWorld(): valid_locations = [location.name for location in self.get_unfilled_locations(player)] else: valid_locations = location_names + relevant_cache = self.regions.location_cache[player] for location_name in valid_locations: - location = self._location_cache.get((location_name, player), None) - if location is not None and location.item is None: + location = relevant_cache.get(location_name, None) + if location and location.item is None: yield location def unlocks_new_location(self, item: Item) -> bool: temp_state = self.state.copy() temp_state.collect(item, True) - for location in self.get_unfilled_locations(): + for location in self.get_unfilled_locations(item.player): if temp_state.can_reach(location) and not self.state.can_reach(location): return True @@ -511,7 +496,7 @@ class MultiWorld(): else: return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) - def can_beat_game(self, starting_state: Optional[CollectionState] = None): + def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool: if starting_state: if self.has_beaten_game(starting_state): return True @@ -524,7 +509,7 @@ class MultiWorld(): and location.item.advancement and location not in state.locations_checked} while prog_locations: - sphere = set() + sphere: Set[Location] = set() # build up spheres of collection radius. # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in prog_locations: @@ -544,12 +529,19 @@ class MultiWorld(): return False - def get_spheres(self): + def get_spheres(self) -> Iterator[Set[Location]]: + """ + yields a set of locations for each logical sphere + + If there are unreachable locations, the last sphere of reachable + locations is followed by an empty set, and then a set of all of the + unreachable locations. + """ state = CollectionState(self) locations = set(self.get_filled_locations()) while locations: - sphere = set() + sphere: Set[Location] = set() for location in locations: if location.can_reach(state): @@ -631,7 +623,7 @@ PathValue = Tuple[str, Optional["PathValue"]] class CollectionState(): - prog_items: typing.Counter[Tuple[str, int]] + prog_items: Dict[int, Counter[str]] multiworld: MultiWorld reachable_regions: Dict[int, Set[Region]] blocked_connections: Dict[int, Set[Entrance]] @@ -643,7 +635,7 @@ class CollectionState(): additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] def __init__(self, parent: MultiWorld): - self.prog_items = Counter() + self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()} @@ -659,39 +651,39 @@ class CollectionState(): def update_reachable_regions(self, player: int): self.stale[player] = False - rrp = self.reachable_regions[player] - bc = self.blocked_connections[player] + reachable_regions = self.reachable_regions[player] + blocked_connections = self.blocked_connections[player] queue = deque(self.blocked_connections[player]) - start = self.multiworld.get_region('Menu', player) + start = self.multiworld.get_region("Menu", player) # init on first call - this can't be done on construction since the regions don't exist yet - if start not in rrp: - rrp.add(start) - bc.update(start.exits) + if start not in reachable_regions: + reachable_regions.add(start) + blocked_connections.update(start.exits) queue.extend(start.exits) # run BFS on all connections, and keep track of those blocked by missing items while queue: connection = queue.popleft() new_region = connection.connected_region - if new_region in rrp: - bc.remove(connection) + if new_region in reachable_regions: + blocked_connections.remove(connection) elif connection.can_reach(self): assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - rrp.add(new_region) - bc.remove(connection) - bc.update(new_region.exits) + reachable_regions.add(new_region) + blocked_connections.remove(connection) + blocked_connections.update(new_region.exits) queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) # Retry connections if the new region can unblock them for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): - if new_entrance in bc and new_entrance not in queue: + if new_entrance in blocked_connections and new_entrance not in queue: queue.append(new_entrance) def copy(self) -> CollectionState: ret = CollectionState(self.multiworld) - ret.prog_items = self.prog_items.copy() + ret.prog_items = copy.deepcopy(self.prog_items) ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in self.reachable_regions} ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in @@ -734,37 +726,43 @@ class CollectionState(): assert isinstance(event.item, Item), "tried to collect Event with no Item" self.collect(event.item, True, event) + # item name related def has(self, item: str, player: int, count: int = 1) -> bool: - return self.prog_items[item, player] >= count + return self.prog_items[player][item] >= count - def has_all(self, items: Set[str], player: int) -> bool: + def has_all(self, items: Iterable[str], player: int) -> bool: """Returns True if each item name of items is in state at least once.""" - return all(self.prog_items[item, player] for item in items) + return all(self.prog_items[player][item] for item in items) - def has_any(self, items: Set[str], player: int) -> bool: + def has_any(self, items: Iterable[str], player: int) -> bool: """Returns True if at least one item name of items is in state at least once.""" - return any(self.prog_items[item, player] for item in items) + return any(self.prog_items[player][item] for item in items) def count(self, item: str, player: int) -> int: - return self.prog_items[item, player] + return self.prog_items[player][item] + def item_count(self, item: str, player: int) -> int: + Utils.deprecate("Use count instead.") + return self.count(item, player) + + # item name group related def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: found: int = 0 + player_prog_items = self.prog_items[player] for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: - found += self.prog_items[item_name, player] + found += player_prog_items[item_name] if found >= count: return True return False def count_group(self, item_name_group: str, player: int) -> int: found: int = 0 + player_prog_items = self.prog_items[player] for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: - found += self.prog_items[item_name, player] + found += player_prog_items[item_name] return found - def item_count(self, item: str, player: int) -> int: - return self.prog_items[item, player] - + # Item related def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool: if location: self.locations_checked.add(location) @@ -772,7 +770,7 @@ class CollectionState(): changed = self.multiworld.worlds[item.player].collect(self, item) if not changed and event: - self.prog_items[item.name, item.player] += 1 + self.prog_items[item.player][item.name] += 1 changed = True self.stale[item.player] = True @@ -839,15 +837,83 @@ class Region: locations: List[Location] entrance_type: ClassVar[Type[Entrance]] = Entrance + class Register(MutableSequence): + region_manager: MultiWorld.RegionManager + + def __init__(self, region_manager: MultiWorld.RegionManager): + self._list = [] + self.region_manager = region_manager + + def __getitem__(self, index: int) -> Location: + return self._list.__getitem__(index) + + def __setitem__(self, index: int, value: Location) -> None: + raise NotImplementedError() + + def __len__(self) -> int: + return self._list.__len__() + + # This seems to not be needed, but that's a bit suspicious. + # def __del__(self): + # self.clear() + + def copy(self): + return self._list.copy() + + class LocationRegister(Register): + def __delitem__(self, index: int) -> None: + location: Location = self._list.__getitem__(index) + self._list.__delitem__(index) + del(self.region_manager.location_cache[location.player][location.name]) + + def insert(self, index: int, value: Location) -> None: + self._list.insert(index, value) + self.region_manager.location_cache[value.player][value.name] = value + + class EntranceRegister(Register): + def __delitem__(self, index: int) -> None: + entrance: Entrance = self._list.__getitem__(index) + self._list.__delitem__(index) + del(self.region_manager.entrance_cache[entrance.player][entrance.name]) + + def insert(self, index: int, value: Entrance) -> None: + self._list.insert(index, value) + self.region_manager.entrance_cache[value.player][value.name] = value + + _locations: LocationRegister[Location] + _exits: EntranceRegister[Entrance] + def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name self.entrances = [] - self.exits = [] - self.locations = [] + self._exits = self.EntranceRegister(multiworld.regions) + self._locations = self.LocationRegister(multiworld.regions) self.multiworld = multiworld self._hint_text = hint self.player = player + def get_locations(self): + return self._locations + + def set_locations(self, new): + if new is self._locations: + return + self._locations.clear() + self._locations.extend(new) + + locations = property(get_locations, set_locations) + + def get_exits(self): + return self._exits + + def set_exits(self, new): + if new is self._exits: + return + self._exits.clear() + self._exits.extend(new) + + exits = property(get_exits, set_exits) + def can_reach(self, state: CollectionState) -> bool: if state.stale[self.player]: state.update_reachable_regions(self.player) @@ -869,19 +935,19 @@ class Region: """ 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: + rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type: """ 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 +955,12 @@ class Region: if rule: exit_.access_rule = rule exit_.connect(connecting_region) - + return exit_ + 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 +1330,7 @@ class Spoiler: 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 +1348,7 @@ class Spoiler: 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 0000000000..86c8e5197e --- /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 61fad65897..c4d80f3416 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 @@ class CommonContext: 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 @@ class CommonContext: 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.""" @@ -731,7 +737,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict): elif 'InvalidGame' in errors: ctx.event_invalid_game() elif 'IncompatibleVersion' in errors: - raise Exception('Server reported your client version as incompatible') + raise Exception('Server reported your client version as incompatible. ' + 'This probably means you have to update.') elif 'InvalidItemsHandling' in errors: raise Exception('The item handling flags requested by the client are not supported') # last to check, recoverable problem @@ -752,6 +759,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} ctx.hint_points = args.get("hint_points", 0) ctx.consume_players_package(args["players"]) + ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") msgs = [] if ctx.locations_checked: msgs.append({"cmd": "LocationChecks", @@ -830,10 +838,14 @@ async def process_server_cmd(ctx: CommonContext, args: dict): elif cmd == "Retrieved": ctx.stored_data.update(args["keys"]) + if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]: + ctx.ui.update_hints() elif cmd == "SetReply": ctx.stored_data[args["key"]] = args["value"] - if args["key"].startswith("EnergyLink"): + if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: + ctx.ui.update_hints() + elif args["key"].startswith("EnergyLink"): ctx.current_energy_link_value = args["value"] if ctx.ui: ctx.ui.set_new_energy_link_value() @@ -876,7 +888,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 21759eefe4..525d27d338 100644 --- a/Fill.py +++ b/Fill.py @@ -5,6 +5,8 @@ import typing 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 @@ -13,6 +15,10 @@ class FillError(RuntimeError): pass +def _log_fill_progress(name: str, placed: int, total_items: int) -> None: + logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.") + + def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState: new_state = base_state.copy() for item in itempool: @@ -24,7 +30,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None, - allow_partial: bool = False, allow_excluded: bool = False) -> None: + allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None: """ :param world: Multiworld to be filled. :param base_state: State assumed before fill. @@ -36,16 +42,20 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: :param on_place: callback that is called when a placement happens :param allow_partial: only place what is possible. Remaining items will be in the item_pool list. :param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations + :param name: name of this fill step for progress logging purposes """ unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] cleanup_required = False - swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter() reachable_items: typing.Dict[int, typing.Deque[Item]] = {} for item in item_pool: reachable_items.setdefault(item.player, deque()).append(item) + # for progress logging + total = min(len(item_pool), len(locations)) + placed = 0 + while any(reachable_items.values()) and locations: # grab one item per player items_to_place = [items.pop() @@ -70,7 +80,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 @@ -102,7 +112,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: location.item = None placed_item.location = None - swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else []) + swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool) # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic # to clean that up later, so there is a chance generation fails. @@ -150,9 +160,15 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill.locked = lock placements.append(spot_to_fill) spot_to_fill.event = item_to_place.advancement + placed += 1 + if not placed % 1000: + _log_fill_progress(name, placed, total) if on_place: on_place(spot_to_fill) + if total > 1000: + _log_fill_progress(name, placed, total) + if cleanup_required: # validate all placements and remove invalid ones state = sweep_from_pool(base_state, []) @@ -196,6 +212,8 @@ def remaining_fill(world: MultiWorld, unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() + total = min(len(itempool), len(locations)) + placed = 0 while locations and itempool: item_to_place = itempool.pop() spot_to_fill: typing.Optional[Location] = None @@ -245,6 +263,12 @@ def remaining_fill(world: MultiWorld, world.push_item(spot_to_fill, item_to_place, False) placements.append(spot_to_fill) + placed += 1 + if not placed % 1000: + _log_fill_progress("Remaining", placed, total) + + if total > 1000: + _log_fill_progress("Remaining", placed, total) if unplaced_items and locations: # There are leftover unplaceable items and locations that won't accept them @@ -265,7 +289,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: @@ -280,7 +304,7 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio locations.append(location) if pool and locations: locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) - fill_restrictive(world, state, locations, pool) + fill_restrictive(world, state, locations, pool, name="Accessibility Corrections") def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations): @@ -288,7 +312,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) @@ -350,23 +374,25 @@ def distribute_early_items(world: MultiWorld, player_local = early_local_rest_items[player] fill_restrictive(world, base_state, [loc for loc in early_locations if loc.player == player], - player_local, lock=True, allow_partial=True) + player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}") if player_local: logging.warning(f"Could not fulfill rules of early items: {player_local}") early_rest_items.extend(early_local_rest_items[player]) early_locations = [loc for loc in early_locations if not loc.item] - fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True) + fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, + name="Early Items") early_locations += early_priority_locations for player in world.player_ids: player_local = early_local_prog_items[player] fill_restrictive(world, base_state, [loc for loc in early_locations if loc.player == player], - player_local, lock=True, allow_partial=True) + player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}") if player_local: logging.warning(f"Could not fulfill rules of early items: {player_local}") early_prog_items.extend(player_local) early_locations = [loc for loc in early_locations if not loc.item] - fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True) + fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, + name="Early Progression") unplaced_early_items = early_rest_items + early_prog_items if unplaced_early_items: logging.warning("Ran out of early locations for early items. Failed to place " @@ -420,13 +446,14 @@ def distribute_items_restrictive(world: MultiWorld) -> None: if prioritylocations: # "priority fill" - fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking) + fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking, + name="Priority") accessibility_corrections(world, world.state, prioritylocations, progitempool) defaultlocations = prioritylocations + defaultlocations if progitempool: - # "progression fill" - fill_restrictive(world, world.state, defaultlocations, progitempool) + # "advancement/progression fill" + fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression") if progitempool: raise FillError( f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') @@ -444,7 +471,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None: raise FillError( f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") - restitempool = usefulitempool + filleritempool + restitempool = filleritempool + usefulitempool remaining_fill(world, defaultlocations, restitempool) @@ -523,7 +550,7 @@ def flood_items(world: MultiWorld) -> None: break -def balance_multiworld_progression(world: MultiWorld) -> None: +def balance_multiworld_progression(multiworld: MultiWorld) -> None: # A system to reduce situations where players have no checks remaining, popularly known as "BK mode." # Overall progression balancing algorithm: # Gather up all locations in a sphere. @@ -531,28 +558,28 @@ 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 - for player in world.player_ids - if world.progression_balancing[player] > 0 + player: multiworld.worlds[player].options.progression_balancing / 100 + for player in multiworld.player_ids + if multiworld.worlds[player].options.progression_balancing > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') else: logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.') logging.debug(balanceable_players) - state: CollectionState = CollectionState(world) + state: CollectionState = CollectionState(multiworld) checked_locations: typing.Set[Location] = set() - unchecked_locations: typing.Set[Location] = set(world.get_locations()) + unchecked_locations: typing.Set[Location] = set(multiworld.get_locations()) total_locations_count: typing.Counter[int] = Counter( location.player - for location in world.get_locations() + for location in multiworld.get_locations() if not location.locked ) reachable_locations_count: typing.Dict[int, int] = { player: 0 - for player in world.player_ids - if total_locations_count[player] and len(world.get_filled_locations(player)) != 0 + for player in multiworld.player_ids + if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0 } balanceable_players = { player: balanceable_players[player] @@ -631,7 +658,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None: balancing_unchecked_locations.remove(location) if not location.locked: balancing_reachables[location.player] += 1 - if world.has_beaten_game(balancing_state) or all( + if multiworld.has_beaten_game(balancing_state) or all( item_percentage(player, reachables) >= threshold_percentages[player] for player, reachables in balancing_reachables.items() if player in threshold_percentages): @@ -648,7 +675,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None: locations_to_test = unlocked_locations[player] items_to_test = list(candidate_items[player]) items_to_test.sort() - world.random.shuffle(items_to_test) + multiworld.random.shuffle(items_to_test) while items_to_test: testing = items_to_test.pop() reducing_state = state.copy() @@ -660,8 +687,8 @@ def balance_multiworld_progression(world: MultiWorld) -> None: reducing_state.sweep_for_events(locations=locations_to_test) - if world.has_beaten_game(balancing_state): - if not world.has_beaten_game(reducing_state): + if multiworld.has_beaten_game(balancing_state): + if not multiworld.has_beaten_game(reducing_state): items_to_replace.append(testing) else: reduced_sphere = get_sphere_locations(reducing_state, locations_to_test) @@ -669,33 +696,32 @@ def balance_multiworld_progression(world: MultiWorld) -> None: if p < threshold_percentages[player]: items_to_replace.append(testing) - replaced_items = False + old_moved_item_count = moved_item_count # sort then shuffle to maintain deterministic behaviour, # while allowing use of set for better algorithm growth behaviour elsewhere replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked) - world.random.shuffle(replacement_locations) + multiworld.random.shuffle(replacement_locations) items_to_replace.sort() - world.random.shuffle(items_to_replace) + multiworld.random.shuffle(items_to_replace) # Start swapping items. Since we swap into earlier spheres, no need for accessibility checks. while replacement_locations and items_to_replace: old_location = items_to_replace.pop() - for new_location in replacement_locations: + for i, new_location in enumerate(replacement_locations): if new_location.can_fill(state, old_location.item, False) and \ old_location.can_fill(state, new_location.item, False): - replacement_locations.remove(new_location) + replacement_locations.pop(i) swap_location_item(old_location, new_location) logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " f"displacing {old_location.item} into {old_location}") moved_item_count += 1 state.collect(new_location.item, True, new_location) - replaced_items = True break else: logging.warning(f"Could not Progression Balance {old_location.item}") - if replaced_items: + if old_moved_item_count < moved_item_count: logging.debug(f"Moved {moved_item_count} items so far\n") unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]} for location in get_sphere_locations(state, unlocked): @@ -709,7 +735,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None: state.collect(location.item, True, location) checked_locations |= sphere_locations - if world.has_beaten_game(state): + if multiworld.has_beaten_game(state): break elif not sphere_locations: logging.warning("Progression Balancing ran out of paths.") @@ -753,8 +779,6 @@ def distribute_planned(world: MultiWorld) -> 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] @@ -767,6 +791,9 @@ def distribute_planned(world: MultiWorld) -> None: block['force'] = 'silent' if 'from_pool' not in block: block['from_pool'] = True + elif not isinstance(block['from_pool'], bool): + from_pool_type = type(block['from_pool']) + raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.') if 'world' not in block: target_world = False else: @@ -847,7 +874,7 @@ def distribute_planned(world: MultiWorld) -> None: 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 +924,22 @@ def distribute_planned(world: MultiWorld) -> 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 5d44a1db45..e19a7a973f 100644 --- a/Generate.py +++ b/Generate.py @@ -7,8 +7,8 @@ import random import string import urllib.parse import urllib.request -from collections import ChainMap, Counter -from typing import Any, Callable, Dict, Tuple, Union +from collections import Counter +from typing import Any, Dict, Tuple, Union import ModuleUpdate @@ -20,7 +20,7 @@ import Options from BaseClasses import seeddigits, get_seed, PlandoOptions from Main import main as ERmain from settings import get_settings -from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path +from Utils import parse_yamls, version_tuple, __version__, tuplize_version from worlds.alttp import Options as LttPOptions from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.Text import TextTable @@ -53,6 +53,9 @@ def mystery_argparse(): help='List of options that can be set manually. Can be combined, for example "bosses, items"') parser.add_argument("--skip_prog_balancing", action="store_true", help="Skip progression balancing step during generation.") + parser.add_argument("--skip_output", action="store_true", + help="Skips generation assertion and output stages and skips multidata and spoiler output. " + "Intended for debugging and testing purposes.") args = parser.parse_args() if not os.path.isabs(args.weights_file_path): args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) @@ -127,6 +130,13 @@ def main(args=None, callback=ERmain): player_id += 1 args.multi = max(player_id - 1, args.multi) + + if args.multi == 0: + raise ValueError( + "No individual player files found and number of players is 0. " + "Provide individual player files or specify the number of players via host.yaml or --multi." + ) + logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, " f"{seed_name} Seed {seed} with plando: {args.plando}") @@ -143,6 +153,7 @@ def main(args=None, callback=ERmain): erargs.outputname = seed_name erargs.outputpath = args.outputpath erargs.skip_prog_balancing = args.skip_prog_balancing + erargs.skip_output = args.skip_output settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) @@ -157,7 +168,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}.") @@ -168,7 +180,7 @@ def main(args=None, callback=ERmain): for player in range(1, args.multi + 1): player_path_cache[player] = player_files.get(player, args.weights_file_path) name_counter = Counter() - erargs.player_settings = {} + erargs.player_options = {} player = 1 while player <= args.multi: @@ -224,7 +236,7 @@ def main(args=None, callback=ERmain): with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: yaml.dump(important, f) - callback(erargs, seed) + return callback(erargs, seed) def read_weights_yamls(path) -> Tuple[Any, ...]: @@ -340,7 +352,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 +457,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 +478,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": @@ -643,6 +650,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): if __name__ == '__main__': import atexit confirmation = atexit.register(input, "Press enter to close.") - main() + multiworld = main() + if __debug__: + import gc + import sys + import weakref + weak = weakref.ref(multiworld) + del multiworld + gc.collect() # need to collect to deref all hard references + assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \ + " This would be a memory leak." # in case of error-free exit should not need confirmation atexit.unregister(confirmation) diff --git a/KH2Client.py b/KH2Client.py index 1134932dc2..69e4adf8bf 100644 --- a/KH2Client.py +++ b/KH2Client.py @@ -1,894 +1,8 @@ -import os -import asyncio import ModuleUpdate -import json import Utils -from pymem import pymem -from worlds.kh2.Items import exclusionItem_table, CheckDupingItems -from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table - -from worlds.kh2.WorldLocations import * - -from worlds import network_data_package - -if __name__ == "__main__": - Utils.init_logging("KH2Client", exception_logger="Client") - -from NetUtils import ClientStatus -from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ - CommonContext, server_loop - +from worlds.kh2.Client import launch ModuleUpdate.update() -kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"] - - -# class KH2CommandProcessor(ClientCommandProcessor): - - -class KH2Context(CommonContext): - # command_processor: int = KH2CommandProcessor - game = "Kingdom Hearts 2" - items_handling = 0b101 # Indicates you get items sent from other worlds. - - def __init__(self, server_address, password): - super(KH2Context, self).__init__(server_address, password) - self.kh2LocalItems = None - self.ability = None - self.growthlevel = None - self.KH2_sync_task = None - self.syncing = False - self.kh2connected = False - self.serverconneced = False - self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()} - self.location_name_to_data = {name: data for name, data, in all_locations.items()} - self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in - item_dictionary_table.items() if data.code} - self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in - all_locations.items() if data.code} - self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()} - - self.location_table = {} - self.collectible_table = {} - self.collectible_override_flags_address = 0 - self.collectible_offsets = {} - self.sending = [] - # list used to keep track of locations+items player has. Used for disoneccting - self.kh2seedsave = None - self.slotDataProgressionNames = {} - self.kh2seedname = None - self.kh2slotdata = None - self.itemamount = {} - # sora equipped, valor equipped, master equipped, final equipped - self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4) - if "localappdata" in os.environ: - self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP") - self.amountOfPieces = 0 - # hooked object - self.kh2 = None - self.ItemIsSafe = False - self.game_connected = False - self.finalxemnas = False - self.worldid = { - # 1: {}, # world of darkness (story cutscenes) - 2: TT_Checks, - # 3: {}, # destiny island doesn't have checks to ima put tt checks here - 4: HB_Checks, - 5: BC_Checks, - 6: Oc_Checks, - 7: AG_Checks, - 8: LoD_Checks, - 9: HundredAcreChecks, - 10: PL_Checks, - 11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc - 12: DC_Checks, - 13: TR_Checks, - 14: HT_Checks, - 15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb - 16: PR_Checks, - 17: SP_Checks, - 18: TWTNW_Checks, - # 255: {}, # starting screen - } - # 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room - self.sveroom = 0x2A09C00 + 0x41 - # 0 not in battle 1 in yellow battle 2 red battle #short - self.inBattle = 0x2A0EAC4 + 0x40 - self.onDeath = 0xAB9078 - # PC Address anchors - self.Now = 0x0714DB8 - self.Save = 0x09A70B0 - self.Sys3 = 0x2A59DF0 - self.Bt10 = 0x2A74880 - self.BtlEnd = 0x2A0D3E0 - self.Slot1 = 0x2A20C98 - - self.chest_set = set(exclusion_table["Chests"]) - - self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"]) - self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"]) - self.shield_set = set(CheckDupingItems["Weapons"]["Shields"]) - - self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set) - - self.equipment_categories = CheckDupingItems["Equipment"] - self.armor_set = set(self.equipment_categories["Armor"]) - self.accessories_set = set(self.equipment_categories["Accessories"]) - self.all_equipment = self.armor_set.union(self.accessories_set) - - self.Equipment_Anchor_Dict = { - "Armor": [0x2504, 0x2506, 0x2508, 0x250A], - "Accessories": [0x2514, 0x2516, 0x2518, 0x251A]} - - self.AbilityQuantityDict = {} - self.ability_categories = CheckDupingItems["Abilities"] - - self.sora_ability_set = set(self.ability_categories["Sora"]) - self.donald_ability_set = set(self.ability_categories["Donald"]) - self.goofy_ability_set = set(self.ability_categories["Goofy"]) - - self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set) - - self.boost_set = set(CheckDupingItems["Boosts"]) - self.stat_increase_set = set(CheckDupingItems["Stat Increases"]) - self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities} - # Growth:[level 1,level 4,slot] - self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA], - "Quick Run": [0x62, 0x65, 0x25DC], - "Dodge Roll": [0x234, 0x237, 0x25DE], - "Aerial Dodge": [0x066, 0x069, 0x25E0], - "Glide": [0x6A, 0x6D, 0x25E2]} - self.boost_to_anchor_dict = { - "Power Boost": 0x24F9, - "Magic Boost": 0x24FA, - "Defense Boost": 0x24FB, - "AP Boost": 0x24F8} - - self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]] - self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"} - - self.bitmask_item_code = [ - 0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007 - , 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C - , 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023 - , 0x13002A, 0x13002B, 0x13002C, 0x13002D] - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(KH2Context, self).server_auth(password_requested) - await self.get_username() - await self.send_connect() - - async def connection_closed(self): - self.kh2connected = False - self.serverconneced = False - if self.kh2seedname is not None and self.auth is not None: - with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), - 'w') as f: - f.write(json.dumps(self.kh2seedsave, indent=4)) - await super(KH2Context, self).connection_closed() - - async def disconnect(self, allow_autoreconnect: bool = False): - self.kh2connected = False - self.serverconneced = False - if self.kh2seedname not in {None} and self.auth not in {None}: - with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), - 'w') as f: - f.write(json.dumps(self.kh2seedsave, indent=4)) - await super(KH2Context, self).disconnect() - - @property - def endpoints(self): - if self.server: - return [self.server] - else: - return [] - - async def shutdown(self): - if self.kh2seedname not in {None} and self.auth not in {None}: - with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), - 'w') as f: - f.write(json.dumps(self.kh2seedsave, indent=4)) - await super(KH2Context, self).shutdown() - - def on_package(self, cmd: str, args: dict): - if cmd in {"RoomInfo"}: - self.kh2seedname = args['seed_name'] - if not os.path.exists(self.game_communication_path): - os.makedirs(self.game_communication_path) - if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"): - self.kh2seedsave = {"itemIndex": -1, - # back of soras invo is 0x25E2. Growth should be moved there - # Character: [back of invo, front of invo] - "SoraInvo": [0x25D8, 0x2546], - "DonaldInvo": [0x26F4, 0x2658], - "GoofyInvo": [0x280A, 0x276C], - "AmountInvo": { - "ServerItems": { - "Ability": {}, - "Amount": {}, - "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, - "Aerial Dodge": 0, - "Glide": 0}, - "Bitmask": [], - "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, - "Equipment": [], - "Magic": {}, - "StatIncrease": {}, - "Boost": {}, - }, - "LocalItems": { - "Ability": {}, - "Amount": {}, - "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, - "Aerial Dodge": 0, "Glide": 0}, - "Bitmask": [], - "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, - "Equipment": [], - "Magic": {}, - "StatIncrease": {}, - "Boost": {}, - }}, - # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked - "LocationsChecked": [], - "Levels": { - "SoraLevel": 0, - "ValorLevel": 0, - "WisdomLevel": 0, - "LimitLevel": 0, - "MasterLevel": 0, - "FinalLevel": 0, - }, - "SoldEquipment": [], - "SoldBoosts": {"Power Boost": 0, - "Magic Boost": 0, - "Defense Boost": 0, - "AP Boost": 0} - } - with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), - 'wt') as f: - pass - self.locations_checked = set() - elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"): - with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f: - self.kh2seedsave = json.load(f) - self.locations_checked = set(self.kh2seedsave["LocationsChecked"]) - self.serverconneced = True - - if cmd in {"Connected"}: - self.kh2slotdata = args['slot_data'] - self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()} - try: - self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") - logger.info("You are now auto-tracking") - self.kh2connected = True - except Exception as e: - logger.info("Line 247") - if self.kh2connected: - logger.info("Connection Lost") - self.kh2connected = False - logger.info(e) - - if cmd in {"ReceivedItems"}: - start_index = args["index"] - if start_index == 0: - # resetting everything that were sent from the server - self.kh2seedsave["SoraInvo"][0] = 0x25D8 - self.kh2seedsave["DonaldInvo"][0] = 0x26F4 - self.kh2seedsave["GoofyInvo"][0] = 0x280A - self.kh2seedsave["itemIndex"] = - 1 - self.kh2seedsave["AmountInvo"]["ServerItems"] = { - "Ability": {}, - "Amount": {}, - "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, - "Aerial Dodge": 0, - "Glide": 0}, - "Bitmask": [], - "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, - "Equipment": [], - "Magic": {}, - "StatIncrease": {}, - "Boost": {}, - } - if start_index > self.kh2seedsave["itemIndex"]: - self.kh2seedsave["itemIndex"] = start_index - for item in args['items']: - asyncio.create_task(self.give_item(item.item)) - - if cmd in {"RoomUpdate"}: - if "checked_locations" in args: - new_locations = set(args["checked_locations"]) - # TODO: make this take locations from other players on the same slot so proper coop happens - # items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if - # location_id in self.kh2LocalItems.keys()] - self.checked_locations |= new_locations - - async def checkWorldLocations(self): - try: - currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big") - if currentworldint in self.worldid: - curworldid = self.worldid[currentworldint] - for location, data in curworldid.items(): - locationId = kh2_loc_name_to_id[location] - if locationId not in self.locations_checked \ - and (int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), - "big") & 0x1 << data.bitIndex) > 0: - self.sending = self.sending + [(int(locationId))] - except Exception as e: - logger.info("Line 285") - if self.kh2connected: - logger.info("Connection Lost.") - self.kh2connected = False - logger.info(e) - - async def checkLevels(self): - try: - for location, data in SoraLevels.items(): - currentLevel = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big") - locationId = kh2_loc_name_to_id[location] - if locationId not in self.locations_checked \ - and currentLevel >= data.bitIndex: - if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel: - self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel - self.sending = self.sending + [(int(locationId))] - formDict = { - 0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels], - 3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]} - for i in range(5): - for location, data in formDict[i][1].items(): - formlevel = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big") - locationId = kh2_loc_name_to_id[location] - if locationId not in self.locations_checked \ - and formlevel >= data.bitIndex: - if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]: - self.kh2seedsave["Levels"][formDict[i][0]] = formlevel - self.sending = self.sending + [(int(locationId))] - except Exception as e: - logger.info("Line 312") - if self.kh2connected: - logger.info("Connection Lost.") - self.kh2connected = False - logger.info(e) - - async def checkSlots(self): - try: - for location, data in weaponSlots.items(): - locationId = kh2_loc_name_to_id[location] - if locationId not in self.locations_checked: - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), - "big") > 0: - self.sending = self.sending + [(int(locationId))] - - for location, data in formSlots.items(): - locationId = kh2_loc_name_to_id[location] - if locationId not in self.locations_checked: - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), - "big") & 0x1 << data.bitIndex > 0: - # self.locations_checked - self.sending = self.sending + [(int(locationId))] - - except Exception as e: - if self.kh2connected: - logger.info("Line 333") - logger.info("Connection Lost.") - self.kh2connected = False - logger.info(e) - - async def verifyChests(self): - try: - for location in self.locations_checked: - locationName = self.lookup_id_to_Location[location] - if locationName in self.chest_set: - if locationName in self.location_name_to_worlddata.keys(): - locationData = self.location_name_to_worlddata[locationName] - if int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1), - "big") & 0x1 << locationData.bitIndex == 0: - roomData = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, - 1), "big") - self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, - (roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1) - - except Exception as e: - if self.kh2connected: - logger.info("Line 350") - logger.info("Connection Lost.") - self.kh2connected = False - logger.info(e) - - async def verifyLevel(self): - for leveltype, anchor in {"SoraLevel": 0x24FF, - "ValorLevel": 0x32F6, - "WisdomLevel": 0x332E, - "LimitLevel": 0x3366, - "MasterLevel": 0x339E, - "FinalLevel": 0x33D6}.items(): - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \ - self.kh2seedsave["Levels"][leveltype]: - self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor, - (self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1) - - async def give_item(self, item, ItemType="ServerItems"): - try: - itemname = self.lookup_id_to_item[item] - itemcode = self.item_name_to_data[itemname] - if itemcode.ability: - abilityInvoType = 0 - TwilightZone = 2 - if ItemType == "LocalItems": - abilityInvoType = 1 - TwilightZone = -2 - if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}: - self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1 - return - - if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]: - self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = [] - # appending the slot that the ability should be in - - if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \ - self.AbilityQuantityDict[itemname]: - if itemname in self.sora_ability_set: - self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append( - self.kh2seedsave["SoraInvo"][abilityInvoType]) - self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone - elif itemname in self.donald_ability_set: - self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append( - self.kh2seedsave["DonaldInvo"][abilityInvoType]) - self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone - else: - self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append( - self.kh2seedsave["GoofyInvo"][abilityInvoType]) - self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone - - elif itemcode.code in self.bitmask_item_code: - - if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]: - self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname) - - elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}: - - if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]: - self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1 - else: - self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1 - elif itemname in self.all_equipment: - - self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname) - - elif itemname in self.all_weapons: - if itemname in self.keyblade_set: - self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname) - elif itemname in self.staff_set: - self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname) - else: - self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname) - - elif itemname in self.boost_set: - if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]: - self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1 - else: - self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1 - - elif itemname in self.stat_increase_set: - - if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]: - self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1 - else: - self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1 - - else: - if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]: - self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1 - else: - self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1 - - except Exception as e: - if self.kh2connected: - logger.info("Line 398") - logger.info("Connection Lost.") - self.kh2connected = False - logger.info(e) - - def run_gui(self): - """Import kivy UI system and start running it as self.ui_task.""" - from kvui import GameManager - - class KH2Manager(GameManager): - logging_pairs = [ - ("Client", "Archipelago") - ] - base_title = "Archipelago KH2 Client" - - self.ui = KH2Manager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - - async def IsInShop(self, sellable, master_boost): - # journal = 0x741230 shop = 0x741320 - # if journal=-1 and shop = 5 then in shop - # if journam !=-1 and shop = 10 then journal - journal = self.kh2.read_short(self.kh2.base_address + 0x741230) - shop = self.kh2.read_short(self.kh2.base_address + 0x741320) - if (journal == -1 and shop == 5) or (journal != -1 and shop == 10): - # print("your in the shop") - sellable_dict = {} - for itemName in sellable: - itemdata = self.item_name_to_data[itemName] - amount = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big") - sellable_dict[itemName] = amount - while (journal == -1 and shop == 5) or (journal != -1 and shop == 10): - journal = self.kh2.read_short(self.kh2.base_address + 0x741230) - shop = self.kh2.read_short(self.kh2.base_address + 0x741320) - await asyncio.sleep(0.5) - for item, amount in sellable_dict.items(): - itemdata = self.item_name_to_data[item] - afterShop = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big") - if afterShop < amount: - if item in master_boost: - self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop) - else: - self.kh2seedsave["SoldEquipment"].append(item) - - async def verifyItems(self): - try: - local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys()) - server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys()) - master_amount = local_amount | server_amount - - local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys()) - server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys()) - master_ability = local_ability | server_ability - - local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"]) - server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"]) - master_bitmask = local_bitmask | server_bitmask - - local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"]) - local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"]) - local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"]) - - server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"]) - server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"]) - server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"]) - - master_keyblade = local_keyblade | server_keyblade - master_staff = local_staff | server_staff - master_shield = local_shield | server_shield - - local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"]) - server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"]) - master_equipment = local_equipment | server_equipment - - local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys()) - server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys()) - master_magic = local_magic | server_magic - - local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys()) - server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys()) - master_stat = local_stat | server_stat - - local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys()) - server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys()) - master_boost = local_boost | server_boost - - master_sell = master_equipment | master_staff | master_shield | master_boost - await asyncio.create_task(self.IsInShop(master_sell, master_boost)) - for itemName in master_amount: - itemData = self.item_name_to_data[itemName] - amountOfItems = 0 - if itemName in local_amount: - amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName] - if itemName in server_amount: - amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName] - - if itemName == "Torn Page": - # Torn Pages are handled differently because they can be consumed. - # Will check the progression in 100 acre and - the amount of visits - # amountofitems-amount of visits done - for location, data in tornPageLocks.items(): - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), - "big") & 0x1 << data.bitIndex > 0: - amountOfItems -= 1 - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != amountOfItems and amountOfItems >= 0: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - amountOfItems.to_bytes(1, 'big'), 1) - - for itemName in master_keyblade: - itemData = self.item_name_to_data[itemName] - # if the inventory slot for that keyblade is less than the amount they should have - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1), - "big") != 13: - # Checking form anchors for the keyblade - if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \ - or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \ - or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \ - or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (0).to_bytes(1, 'big'), 1) - else: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (1).to_bytes(1, 'big'), 1) - for itemName in master_staff: - itemData = self.item_name_to_data[itemName] - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != 1 \ - and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \ - and itemName not in self.kh2seedsave["SoldEquipment"]: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (1).to_bytes(1, 'big'), 1) - - for itemName in master_shield: - itemData = self.item_name_to_data[itemName] - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != 1 \ - and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \ - and itemName not in self.kh2seedsave["SoldEquipment"]: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (1).to_bytes(1, 'big'), 1) - - for itemName in master_ability: - itemData = self.item_name_to_data[itemName] - ability_slot = [] - if itemName in local_ability: - ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName] - if itemName in server_ability: - ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName] - for slot in ability_slot: - current = self.kh2.read_short(self.kh2.base_address + self.Save + slot) - ability = current & 0x0FFF - if ability | 0x8000 != (0x8000 + itemData.memaddr): - if current - 0x8000 > 0: - self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr)) - else: - self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr) - # removes the duped ability if client gave faster than the game. - for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}: - if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \ - self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]: - self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0) - # remove the dummy level 1 growths if they are in these invo slots. - for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}: - current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot) - ability = current & 0x0FFF - if 0x05E <= ability <= 0x06D: - self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0) - - for itemName in self.master_growth: - growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \ - + self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName] - if growthLevel > 0: - slot = self.growth_values_dict[itemName][2] - min_growth = self.growth_values_dict[itemName][0] - max_growth = self.growth_values_dict[itemName][1] - if growthLevel > 4: - growthLevel = 4 - current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot) - ability = current_growth_level & 0x0FFF - # if the player should be getting a growth ability - if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel: - # if it should be level one of that growth - if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth: - self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth) - # if it is already in the inventory - elif ability | 0x8000 < (0x8000 + max_growth): - self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1) - - for itemName in master_bitmask: - itemData = self.item_name_to_data[itemName] - itemMemory = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big") - if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") & 0x1 << itemData.bitmask) == 0: - # when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game. - if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}: - self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410, - (0).to_bytes(1, 'big'), 1) - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1) - - for itemName in master_equipment: - itemData = self.item_name_to_data[itemName] - isThere = False - if itemName in self.accessories_set: - Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"] - else: - Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"] - # Checking form anchors for the equipment - for slot in Equipment_Anchor_List: - if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id: - isThere = True - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != 0: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (0).to_bytes(1, 'big'), 1) - break - if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]: - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != 1: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (1).to_bytes(1, 'big'), 1) - - for itemName in master_magic: - itemData = self.item_name_to_data[itemName] - amountOfItems = 0 - if itemName in local_magic: - amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName] - if itemName in server_magic: - amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName] - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != amountOfItems \ - and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - amountOfItems.to_bytes(1, 'big'), 1) - - for itemName in master_stat: - itemData = self.item_name_to_data[itemName] - amountOfItems = 0 - if itemName in local_stat: - amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName] - if itemName in server_stat: - amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName] - - # 0x130293 is Crit_1's location id for touching the computer - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") != amountOfItems \ - and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1), - "big") >= 5 and int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1), - "big") > 0: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - amountOfItems.to_bytes(1, 'big'), 1) - - for itemName in master_boost: - itemData = self.item_name_to_data[itemName] - amountOfItems = 0 - if itemName in local_boost: - amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName] - if itemName in server_boost: - amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName] - amountOfBoostsInInvo = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), - "big") - amountOfUsedBoosts = int.from_bytes( - self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1), - "big") - # Ap Boots start at +50 for some reason - if itemName == "AP Boost": - amountOfUsedBoosts -= 50 - totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts) - if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][ - itemName] and amountOfBoostsInInvo < 255: - self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, - (amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1) - - except Exception as e: - logger.info("Line 573") - if self.kh2connected: - logger.info("Connection Lost.") - self.kh2connected = False - logger.info(e) - - -def finishedGame(ctx: KH2Context, message): - if ctx.kh2slotdata['FinalXemnas'] == 1: - if 0x1301ED in message[0]["locations"]: - ctx.finalxemnas = True - # three proofs - if ctx.kh2slotdata['Goal'] == 0: - if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \ - and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \ - and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0: - if ctx.kh2slotdata['FinalXemnas'] == 1: - if ctx.finalxemnas: - return True - else: - return False - else: - return True - else: - return False - elif ctx.kh2slotdata['Goal'] == 1: - if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \ - ctx.kh2slotdata['LuckyEmblemsRequired']: - ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1) - ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1) - ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1) - if ctx.kh2slotdata['FinalXemnas'] == 1: - if ctx.finalxemnas: - return True - else: - return False - else: - return True - else: - return False - elif ctx.kh2slotdata['Goal'] == 2: - for boss in ctx.kh2slotdata["hitlist"]: - if boss in message[0]["locations"]: - ctx.amountOfPieces += 1 - if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]: - ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1) - ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1) - ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1) - if ctx.kh2slotdata['FinalXemnas'] == 1: - if ctx.finalxemnas: - return True - else: - return False - else: - return True - else: - return False - - -async def kh2_watcher(ctx: KH2Context): - while not ctx.exit_event.is_set(): - try: - if ctx.kh2connected and ctx.serverconneced: - ctx.sending = [] - await asyncio.create_task(ctx.checkWorldLocations()) - await asyncio.create_task(ctx.checkLevels()) - await asyncio.create_task(ctx.checkSlots()) - await asyncio.create_task(ctx.verifyChests()) - await asyncio.create_task(ctx.verifyItems()) - await asyncio.create_task(ctx.verifyLevel()) - message = [{"cmd": 'LocationChecks', "locations": ctx.sending}] - if finishedGame(ctx, message): - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - location_ids = [] - location_ids = [location for location in message[0]["locations"] if location not in location_ids] - for location in location_ids: - if location not in ctx.locations_checked: - ctx.locations_checked.add(location) - ctx.kh2seedsave["LocationsChecked"].append(location) - if location in ctx.kh2LocalItems: - item = ctx.kh2slotdata["LocalItems"][str(location)] - await asyncio.create_task(ctx.give_item(item, "LocalItems")) - await ctx.send_msgs(message) - elif not ctx.kh2connected and ctx.serverconneced: - logger.info("Game is not open. Disconnecting from Server.") - await ctx.disconnect() - except Exception as e: - logger.info("Line 661") - if ctx.kh2connected: - logger.info("Connection Lost.") - ctx.kh2connected = False - logger.info(e) - await asyncio.sleep(0.5) - - if __name__ == '__main__': - async def main(args): - ctx = KH2Context(args.connect, args.password) - ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - progression_watcher = asyncio.create_task( - kh2_watcher(ctx), name="KH2ProgressionWatcher") - - await ctx.exit_event.wait() - ctx.server_address = None - - await progression_watcher - - await ctx.shutdown() - - - import colorama - - parser = get_base_parser(description="KH2 Client, for text interfacing.") - - args, rest = parser.parse_known_args() - colorama.init() - asyncio.run(main(args)) - colorama.deinit() + Utils.init_logging("KH2Client", exception_logger="Client") + launch() diff --git a/Launcher.py b/Launcher.py index a1548d594c..9e184bf108 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 802ec47dd1..9c5bd10244 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -1004,6 +1004,7 @@ class SpriteSelector(): 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/MMBN3Client.py b/MMBN3Client.py index 3f7474a6fd..140a98745c 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -58,7 +58,7 @@ class MMBN3CommandProcessor(ClientCommandProcessor): class MMBN3Context(CommonContext): command_processor = MMBN3CommandProcessor game = "MegaMan Battle Network 3" - items_handling = 0b001 # full local + items_handling = 0b101 # full local except starting items def __init__(self, server_address, password): super().__init__(server_address, password) diff --git a/Main.py b/Main.py index 48b37764a9..8dac8f7d20 100644 --- a/Main.py +++ b/Main.py @@ -13,8 +13,8 @@ import worlds from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items from Options import StartInventoryPool -from settings import get_settings from Utils import __version__, output_path, version_tuple +from settings import get_settings from worlds import AutoWorld from worlds.generic.Rules import exclusion_rules, locality_rules @@ -101,20 +101,33 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No del item_digits, location_digits, item_count, location_count - AutoWorld.call_stage(world, "assert_generate") + # This assertion method should not be necessary to run if we are not outputting any multidata. + if not args.skip_output: + AutoWorld.call_stage(world, "assert_generate") AutoWorld.call_all(world, "generate_early") 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)) for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items(): for _ in range(count): world.push_precollected(world.create_item(item_name, player)) + # remove from_pool items also from early items handling, as starting is plenty early. + early = world.early_items[player].get(item_name, 0) + if early: + world.early_items[player][item_name] = max(0, early-count) + remaining_count = count-early + if remaining_count > 0: + local_early = world.early_local_items[player].get(item_name, 0) + if local_early: + world.early_items[player][item_name] = max(0, local_early - remaining_count) + del local_early + del early logger.info('Creating World.') AutoWorld.call_all(world, "create_regions") @@ -122,23 +135,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info('Creating Items.') AutoWorld.call_all(world, "create_items") - # All worlds should have finished creating all regions, locations, and entrances. - # Recache to ensure that they are all visible for locality rules. - world._recache() - logger.info('Calculating Access Rules.') 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: + 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 @@ -151,8 +160,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No 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") @@ -233,7 +242,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No region = Region("Menu", group_id, world, "ItemLink") world.regions.append(region) - locations = region.locations = [] + locations = region.locations for item in world.itempool: count = common_item_count.get(item.player, {}).get(item.name, 0) if count: @@ -267,10 +276,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.itempool.extend(items_to_add[:itemcount - len(world.itempool)]) if any(world.item_links.values()): - world._recache() world._all_state = None - logger.info("Running Item Plando") + logger.info("Running Item Plando.") distribute_planned(world) @@ -292,24 +300,28 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No else: logger.info("Progression balancing skipped.") - logger.info(f'Beginning output...') - # we're about to output using multithreading, so we're removing the global random state to prevent accidental use world.random.passthrough = False + if args.skip_output: + logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start) + return world + + logger.info(f'Beginning output...') outfilebase = 'AP_' + world.seed_name 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]] = {} @@ -358,13 +370,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No assert location.item.code is not None, "item code None should be event, " \ "location.address should then also be None. Location: " \ f" {location}" + assert location.address not in locations_data[location.player], ( + f"Locations with duplicate address. {location} and " + f"{locations_data[location.player][location.address]}") 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) diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 209f2da672..c33e894e8b 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -67,14 +67,23 @@ def update(yes=False, force=False): install_pkg_resources(yes=yes) import pkg_resources + prev = "" # if a line ends in \ we store here and merge later for req_file in requirements_files: path = os.path.join(os.path.dirname(sys.argv[0]), req_file) if not os.path.exists(path): path = os.path.join(os.path.dirname(__file__), req_file) with open(path) as requirementsfile: for line in requirementsfile: - if not line or line[0] == "#": - continue # ignore comments + if not line or line.lstrip(" \t")[0] == "#": + if not prev: + continue # ignore comments + line = "" + elif line.rstrip("\r\n").endswith("\\"): + prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line + continue + line = prev + line + line = line.split("--hash=")[0] # remove hashes from requirement for version checking + prev = "" if line.startswith(("https://", "git+https://")): # extract name and version for url rest = line.split('/')[-1] diff --git a/MultiServer.py b/MultiServer.py index 8be8d64132..9d2e9b564e 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -2,14 +2,15 @@ from __future__ import annotations import argparse import asyncio -import copy import collections +import copy import datetime import functools import hashlib import inspect import itertools import logging +import math import operator import pickle import random @@ -67,21 +68,25 @@ def update_dict(dictionary, entries): # functions callable on storable data on the server by clients modify_functions = { - "add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append) - "mul": operator.mul, - "mod": operator.mod, - "max": max, - "min": min, + # generic: "replace": lambda old, new: new, "default": lambda old, new: old, + # numeric: + "add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append) + "mul": operator.mul, "pow": operator.pow, + "mod": operator.mod, + "floor": lambda value, _: math.floor(value), + "ceil": lambda value, _: math.ceil(value), + "max": max, + "min": min, # bitwise: "xor": operator.xor, "or": operator.or_, "and": operator.and_, "left_shift": operator.lshift, "right_shift": operator.rshift, - # lists/dicts + # lists/dicts: "remove": remove_from_list, "pop": pop_from_container, "update": update_dict, @@ -412,6 +417,8 @@ class Context: self.player_name_lookup[slot_info.name] = 0, slot_id self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \ list(self.get_rechecked_hints(local_team, local_player)) + self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \ + self.client_game_state[local_team, local_player] self.seed_name = decoded_obj["seed_name"] self.random.seed(self.seed_name) @@ -707,6 +714,12 @@ class Context: "hint_points": get_slot_points(self, team, slot) }]) + def on_client_status_change(self, team: int, slot: int): + key: str = f"_read_client_status_{team}_{slot}" + targets: typing.Set[Client] = set(self.stored_data_notification_clients[key]) + if targets: + self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}]) + def update_aliases(ctx: Context, team: int): cmd = ctx.dumper([{"cmd": "RoomUpdate", @@ -1814,6 +1827,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus) ctx.on_goal_achieved(client) ctx.client_game_state[client.team, client.slot] = new_status + ctx.on_client_status_change(client.team, client.slot) ctx.save() diff --git a/Options.py b/Options.py index 960e6c19d1..2e3927aae3 100644 --- a/Options.py +++ b/Options.py @@ -2,6 +2,9 @@ from __future__ import annotations import abc import logging +from copy import deepcopy +from dataclasses import dataclass +import functools import math import numbers import random @@ -211,6 +214,12 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC): 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) @@ -687,11 +696,19 @@ class Range(NumericOption): return int(round(random.triangular(lower, end, tri), 0)) -class SpecialRange(Range): - special_range_cutoff = 0 +class NamedRange(Range): special_range_names: typing.Dict[str, int] = {} """Special Range names have to be all lowercase as matching is done with text.lower()""" + def __init__(self, value: int) -> None: + if value < self.range_start and value not in self.special_range_names.values(): + raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " + + f"and is also not one of the supported named special values: {self.special_range_names}") + elif value > self.range_end and value not in self.special_range_names.values(): + raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + + f"and is also not one of the supported named special values: {self.special_range_names}") + self.value = value + @classmethod def from_text(cls, text: str) -> Range: text = text.lower() @@ -699,6 +716,19 @@ class SpecialRange(Range): return cls(cls.special_range_names[text]) return super().from_text(text) + +class SpecialRange(NamedRange): + special_range_cutoff = 0 + + # TODO: remove class SpecialRange, earliest 3 releases after 0.4.3 + def __new__(cls, value: int) -> SpecialRange: + from Utils import deprecate + deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. " + "Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In " + "NamedRange, range_start specifies the lower end of the regular range, while special values can be " + "placed anywhere (below, inside, or above the regular range).") + return super().__new__(cls, value) + @classmethod def weighted_range(cls, text) -> Range: if text == "random-low": @@ -882,7 +912,7 @@ class Accessibility(Choice): default = 1 -class ProgressionBalancing(SpecialRange): +class ProgressionBalancing(NamedRange): """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. A lower setting means more getting stuck. A higher setting means less getting stuck.""" default = 50 @@ -896,10 +926,58 @@ 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'.") + value = getattr(self, option_name).value + if isinstance(value, set): + value = sorted(value) + option_results[display_name] = value + else: + raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") + return option_results class LocalItems(ItemSet): @@ -1020,17 +1098,16 @@ class ItemLinks(OptionList): 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): @@ -1052,7 +1129,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge if os.path.isfile(full_path) and full_path.endswith(".yaml"): os.unlink(full_path) - def dictify_range(option: typing.Union[Range, SpecialRange]): + def dictify_range(option: Range): data = {option.default: 50} for sub_option in ["random", "random-low", "random-high"]: if sub_option != option.default: @@ -1071,10 +1148,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge 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/PokemonClient.py b/PokemonClient.py deleted file mode 100644 index 6b43a53b8f..0000000000 --- a/PokemonClient.py +++ /dev/null @@ -1,382 +0,0 @@ -import asyncio -import json -import time -import os -import bsdiff4 -import subprocess -import zipfile -from asyncio import StreamReader, StreamWriter -from typing import List - - -import Utils -from Utils import async_start -from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ - get_base_parser - -from worlds.pokemon_rb.locations import location_data -from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch - -location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}} -location_bytes_bits = {} -for location in location_data: - if location.ram_address is not None: - if type(location.ram_address) == list: - location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address - location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit}, - {'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}] - else: - location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address - location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit} - -location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item" - and location.address is not None} - -SYSTEM_MESSAGE_ID = 0 - -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua" -CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua" -CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" -CONNECTION_CONNECTED_STATUS = "Connected" -CONNECTION_INITIAL_STATUS = "Connection has not been initiated" - -DISPLAY_MSGS = True - -SCRIPT_VERSION = 3 - - -class GBCommandProcessor(ClientCommandProcessor): - def __init__(self, ctx: CommonContext): - super().__init__(ctx) - - def _cmd_gb(self): - """Check Gameboy Connection State""" - if isinstance(self.ctx, GBContext): - logger.info(f"Gameboy Status: {self.ctx.gb_status}") - - -class GBContext(CommonContext): - command_processor = GBCommandProcessor - game = 'Pokemon Red and Blue' - - def __init__(self, server_address, password): - super().__init__(server_address, password) - self.gb_streams: (StreamReader, StreamWriter) = None - self.gb_sync_task = None - self.messages = {} - self.locations_array = None - self.gb_status = CONNECTION_INITIAL_STATUS - self.awaiting_rom = False - self.display_msgs = True - self.deathlink_pending = False - self.set_deathlink = False - self.client_compatibility_mode = 0 - self.items_handling = 0b001 - self.sent_release = False - self.sent_collect = False - self.auto_hints = set() - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(GBContext, self).server_auth(password_requested) - if not self.auth: - self.awaiting_rom = True - logger.info('Awaiting connection to EmuHawk to get Player information') - return - - await self.send_connect() - - def _set_message(self, msg: str, msg_id: int): - if DISPLAY_MSGS: - self.messages[(time.time(), msg_id)] = msg - - def on_package(self, cmd: str, args: dict): - if cmd == 'Connected': - self.locations_array = None - if 'death_link' in args['slot_data'] and args['slot_data']['death_link']: - self.set_deathlink = True - elif cmd == "RoomInfo": - self.seed_name = args['seed_name'] - elif cmd == 'Print': - msg = args['text'] - if ': !' not in msg: - self._set_message(msg, SYSTEM_MESSAGE_ID) - elif cmd == "ReceivedItems": - msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" - self._set_message(msg, SYSTEM_MESSAGE_ID) - - def on_deathlink(self, data: dict): - self.deathlink_pending = True - super().on_deathlink(data) - - def run_gui(self): - from kvui import GameManager - - class GBManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago") - ] - base_title = "Archipelago Pokémon Client" - - self.ui = GBManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - - -def get_payload(ctx: GBContext): - current_time = time.time() - ret = json.dumps( - { - "items": [item.item for item in ctx.items_received], - "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() - if key[0] > current_time - 10}, - "deathlink": ctx.deathlink_pending, - "options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled')) - } - ) - ctx.deathlink_pending = False - return ret - - -async def parse_locations(data: List, ctx: GBContext): - locations = [] - flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20], - "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], - "Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]} - - if len(data) > 0x140 + 0x20 + 0x0E + 0x01: - flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:] - else: - flags["DexSanityFlag"] = [0] * 19 - - for flag_type, loc_map in location_map.items(): - for flag, loc_id in loc_map.items(): - if flag_type == "list": - if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit'] - and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']): - locations.append(loc_id) - elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']: - locations.append(loc_id) - - hints = [] - if flags["EventFlag"][280] & 16: - hints.append("Cerulean Bicycle Shop") - if flags["EventFlag"][280] & 32: - hints.append("Route 2 Gate - Oak's Aide") - if flags["EventFlag"][280] & 64: - hints.append("Route 11 Gate 2F - Oak's Aide") - if flags["EventFlag"][280] & 128: - hints.append("Route 15 Gate 2F - Oak's Aide") - if flags["EventFlag"][281] & 1: - hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2", - "Celadon Prize Corner - Item Prize 3"] - if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"] - not in ctx.checked_locations): - hints.append("Fossil - Choice B") - elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"] - not in ctx.checked_locations): - hints.append("Fossil - Choice A") - hints = [ - location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and - location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked - ] - if hints: - await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}]) - ctx.auto_hints.update(hints) - - if flags["EventFlag"][280] & 1 and not ctx.finished_game: - await ctx.send_msgs([ - {"cmd": "StatusUpdate", - "status": 30} - ]) - ctx.finished_game = True - if locations == ctx.locations_array: - return - ctx.locations_array = locations - if locations is not None: - await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}]) - - -async def gb_sync_task(ctx: GBContext): - logger.info("Starting GB connector. Use /gb for status information") - while not ctx.exit_event.is_set(): - error_status = None - if ctx.gb_streams: - (reader, writer) = ctx.gb_streams - msg = get_payload(ctx).encode() - writer.write(msg) - writer.write(b'\n') - try: - await asyncio.wait_for(writer.drain(), timeout=1.5) - try: - # Data will return a dict with up to two fields: - # 1. A keepalive response of the Players Name (always) - # 2. An array representing the memory values of the locations area (if in game) - data = await asyncio.wait_for(reader.readline(), timeout=5) - data_decoded = json.loads(data.decode()) - if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION: - msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \ - "and PokemonClient are from the same Archipelago installation." - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - error_status = CONNECTION_RESET_STATUS - ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion'] - if ctx.client_compatibility_mode == 0: - ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested - if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]): - msg = "The server is running a different multiworld than your client is. (invalid seed_name)" - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - error_status = CONNECTION_RESET_STATUS - ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]) - if not ctx.auth: - ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) - if ctx.auth == '': - msg = "Invalid ROM detected. No player name built into the ROM." - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - error_status = CONNECTION_RESET_STATUS - if ctx.awaiting_rom: - await ctx.server_auth(False) - if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \ - and not error_status and ctx.auth: - # Not just a keep alive ping, parse - async_start(parse_locations(data_decoded['locations'], ctx)) - if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags: - await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!") - if 'options' in data_decoded: - msgs = [] - if data_decoded['options'] & 4 and not ctx.sent_release: - ctx.sent_release = True - msgs.append({"cmd": "Say", "text": "!release"}) - if data_decoded['options'] & 8 and not ctx.sent_collect: - ctx.sent_collect = True - msgs.append({"cmd": "Say", "text": "!collect"}) - if msgs: - await ctx.send_msgs(msgs) - if ctx.set_deathlink: - await ctx.update_death_link(True) - except asyncio.TimeoutError: - logger.debug("Read Timed Out, Reconnecting") - error_status = CONNECTION_TIMING_OUT_STATUS - writer.close() - ctx.gb_streams = None - except ConnectionResetError as e: - logger.debug("Read failed due to Connection Lost, Reconnecting") - error_status = CONNECTION_RESET_STATUS - writer.close() - ctx.gb_streams = None - except TimeoutError: - logger.debug("Connection Timed Out, Reconnecting") - error_status = CONNECTION_TIMING_OUT_STATUS - writer.close() - ctx.gb_streams = None - except ConnectionResetError: - logger.debug("Connection Lost, Reconnecting") - error_status = CONNECTION_RESET_STATUS - writer.close() - ctx.gb_streams = None - if ctx.gb_status == CONNECTION_TENTATIVE_STATUS: - if not error_status: - logger.info("Successfully Connected to Gameboy") - ctx.gb_status = CONNECTION_CONNECTED_STATUS - else: - ctx.gb_status = f"Was tentatively connected but error occured: {error_status}" - elif error_status: - ctx.gb_status = error_status - logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates") - else: - try: - logger.debug("Attempting to connect to Gameboy") - ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10) - ctx.gb_status = CONNECTION_TENTATIVE_STATUS - except TimeoutError: - logger.debug("Connection Timed Out, Trying Again") - ctx.gb_status = CONNECTION_TIMING_OUT_STATUS - continue - except ConnectionRefusedError: - logger.debug("Connection Refused, Trying Again") - ctx.gb_status = CONNECTION_REFUSED_STATUS - continue - - -async def run_game(romfile): - auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True) - if auto_start is True: - import webbrowser - webbrowser.open(romfile) - elif os.path.isfile(auto_start): - subprocess.Popen([auto_start, romfile], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - -async def patch_and_run_game(game_version, patch_file, ctx): - base_name = os.path.splitext(patch_file)[0] - comp_path = base_name + '.gb' - if game_version == "blue": - delta_patch = BlueDeltaPatch - else: - delta_patch = RedDeltaPatch - - try: - base_rom = delta_patch.get_source_data() - except Exception as msg: - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - - with zipfile.ZipFile(patch_file, 'r') as patch_archive: - with patch_archive.open('delta.bsdiff4', 'r') as stream: - patch = stream.read() - patched_rom_data = bsdiff4.patch(base_rom, patch) - - with open(comp_path, "wb") as patched_rom_file: - patched_rom_file.write(patched_rom_data) - - async_start(run_game(comp_path)) - - -if __name__ == '__main__': - - Utils.init_logging("PokemonClient") - - options = Utils.get_options() - - async def main(): - parser = get_base_parser() - parser.add_argument('patch_file', default="", type=str, nargs="?", - help='Path to an APRED or APBLUE patch file') - args = parser.parse_args() - - ctx = GBContext(args.connect, args.password) - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync") - - if args.patch_file: - ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower() - if ext == "apred": - logger.info("APRED file supplied, beginning patching process...") - async_start(patch_and_run_game("red", args.patch_file, ctx)) - elif ext == "apblue": - logger.info("APBLUE file supplied, beginning patching process...") - async_start(patch_and_run_game("blue", args.patch_file, ctx)) - else: - logger.warning(f"Unknown patch file extension {ext}") - - await ctx.exit_event.wait() - ctx.server_address = None - - await ctx.shutdown() - - if ctx.gb_sync_task: - await ctx.gb_sync_task - - - import colorama - - colorama.init() - - asyncio.run(main()) - colorama.deinit() diff --git a/README.md b/README.md index 54b659397f..a1e03293d5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ Currently, the following games are supported: * Muse Dash * DOOM 1993 * Terraria +* Lingo +* Pokémon Emerald +* DOOM II +* Shivers +* Heretic +* Landstalker: The Treasures of King Nole +* Final Fantasy Mystic Quest For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/SNIClient.py b/SNIClient.py index 0909c61382..062d7a7cbe 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -207,12 +207,12 @@ class SNIContext(CommonContext): self.killing_player_task = asyncio.create_task(deathlink_kill_player(self)) super(SNIContext, self).on_deathlink(data) - async def handle_deathlink_state(self, currently_dead: bool) -> None: + async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None: # in this state we only care about triggering a death send if self.death_state == DeathState.alive: if currently_dead: self.death_state = DeathState.dead - await self.send_death() + await self.send_death(death_text) # in this state we care about confirming a kill, to move state to dead elif self.death_state == DeathState.killing_player: # this is being handled in deathlink_kill_player(ctx) already diff --git a/UndertaleClient.py b/UndertaleClient.py index 62fbe128bd..e1538ce81d 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -27,14 +27,14 @@ class UndertaleCommandProcessor(ClientCommandProcessor): self.ctx.syncing = True def _cmd_patch(self): - """Patch the game.""" + """Patch the game. Only use this command if /auto_patch fails.""" if isinstance(self.ctx, UndertaleContext): 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!)""" + """Redirect to proper save data folder. This is necessary for Linux users to use before connecting.""" if isinstance(self.ctx, UndertaleContext): self.ctx.save_game_folder = directory self.output("Changed to the following directory: " + self.ctx.save_game_folder) @@ -67,7 +67,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor): self.output("Patching successful!") def _cmd_online(self): - """Makes you no longer able to see other Undertale players.""" + """Toggles seeing other Undertale players.""" if isinstance(self.ctx, UndertaleContext): self.ctx.update_online_mode(not ("Online" in self.ctx.tags)) if "Online" in self.ctx.tags: diff --git a/Utils.py b/Utils.py index 9ceba48299..f6e4a9ab60 100644 --- a/Utils.py +++ b/Utils.py @@ -5,6 +5,7 @@ import json import typing import builtins import os +import itertools import subprocess import sys import pickle @@ -13,6 +14,7 @@ import io import collections import importlib import logging +import warnings from argparse import Namespace from settings import Settings, get_settings @@ -29,6 +31,7 @@ except ImportError: if typing.TYPE_CHECKING: import tkinter import pathlib + from BaseClasses import Region def tuplize_version(version: str) -> Version: @@ -44,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.4.3" +__version__ = "0.4.4" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -71,6 +74,8 @@ def snes_to_pc(value: int) -> int: RetType = typing.TypeVar("RetType") +S = typing.TypeVar("S") +T = typing.TypeVar("T") def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]: @@ -88,6 +93,31 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[] return _wrap +def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]: + """Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple.""" + + assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache." + + cache_name = f"__cache_{function.__name__}__" + + @functools.wraps(function) + def wrap(self: S, arg: T) -> RetType: + cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]], + getattr(self, cache_name, None)) + if cache is None: + res = function(self, arg) + setattr(self, cache_name, {arg: res}) + return res + try: + return cache[arg] + except KeyError: + res = function(self, arg) + cache[arg] = res + return res + + return wrap + + def is_frozen() -> bool: return typing.cast(bool, getattr(sys, 'frozen', False)) @@ -144,12 +174,16 @@ def user_path(*path: str) -> str: if user_path.cached_path != local_path(): import filecmp if not os.path.exists(user_path("manifest.json")) or \ + not os.path.exists(local_path("manifest.json")) or \ not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): import shutil - for dn in ("Players", "data/sprites"): + for dn in ("Players", "data/sprites", "data/lua"): shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ("manifest.json",): - shutil.copy2(local_path(fn), user_path(fn)) + if not os.path.exists(local_path("manifest.json")): + warnings.warn(f"Upgrading {user_path()} from something that is not a proper install") + else: + shutil.copy2(local_path("manifest.json"), user_path("manifest.json")) + os.makedirs(user_path("worlds"), exist_ok=True) return os.path.join(user_path.cached_path, *path) @@ -215,7 +249,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 +273,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() @@ -243,15 +289,13 @@ def get_public_ipv6() -> str: return ip -OptionsType = Settings # TODO: remove ~2 versions after 0.4.1 +OptionsType = Settings # TODO: remove when removing get_options -@cache_argsless -def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1 - return Settings(None) - - -get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported +def get_options() -> Settings: + # TODO: switch to Utils.deprecate after 0.4.4 + warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning) + return get_settings() def persistent_store(category: str, key: typing.Any, value: typing.Any): @@ -445,11 +489,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 @@ -656,6 +710,11 @@ def messagebox(title: str, text: str, error: bool = False) -> None: 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 @@ -720,6 +779,25 @@ def deprecate(message: str): import warnings warnings.warn(message) + +class DeprecateDict(dict): + log_message: str + should_error: bool + + def __init__(self, message, error: bool = False) -> None: + self.log_message = message + self.should_error = error + super().__init__() + + def __getitem__(self, item: Any) -> Any: + if self.should_error: + deprecate(self.log_message) + elif __debug__: + import warnings + warnings.warn(self.log_message) + return super().__getitem__(item) + + def _extend_freeze_support() -> None: """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" # upstream issue: https://github.com/python/cpython/issues/76327 @@ -766,3 +844,127 @@ 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)) + + +class RepeatableChain: + def __init__(self, iterable: typing.Iterable): + self.iterable = iterable + + def __iter__(self): + return itertools.chain.from_iterable(self.iterable) + + def __bool__(self): + return any(sub_iterable for sub_iterable in self.iterable) + + def __len__(self): + return sum(len(iterable) for iterable in self.iterable) diff --git a/WargrooveClient.py b/WargrooveClient.py index 16bfeb15ab..77180502ce 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -113,6 +113,9 @@ class WargrooveContext(CommonContext): async def connection_closed(self): await super(WargrooveContext, self).connection_closed() self.remove_communication_files() + self.checked_locations.clear() + self.server_locations.clear() + self.finished_game = False @property def endpoints(self): @@ -124,6 +127,9 @@ class WargrooveContext(CommonContext): async def shutdown(self): await super(WargrooveContext, self).shutdown() self.remove_communication_files() + self.checked_locations.clear() + self.server_locations.clear() + self.finished_game = False def remove_communication_files(self): for root, dirs, files in os.walk(self.game_communication_path): @@ -402,8 +408,10 @@ async def game_watcher(ctx: WargrooveContext): if file.find("send") > -1: st = file.split("send", -1)[1] sending = sending+[(int(st))] + os.remove(os.path.join(ctx.game_communication_path, file)) if file.find("victory") > -1: victory = True + os.remove(os.path.join(ctx.game_communication_path, file)) ctx.locations_checked = sending message = [{"cmd": 'LocationChecks', "locations": sending}] await ctx.send_msgs(message) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 49d57c9d9d..8121cbc44b 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -51,7 +51,6 @@ app.config["PONY"] = { } app.config["MAX_ROLL"] = 20 app.config["CACHE_TYPE"] = "SimpleCache" -app.config["JSON_AS_ASCII"] = False app.config["HOST_ADDRESS"] = "" cache = Cache() diff --git a/WebHostLib/check.py b/WebHostLib/check.py index c5dfd9f556..e739dda02d 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,17 +1,13 @@ +import os 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 from WebHostLib import app - -banned_zip_contents = (".sfc",) - - -def allowed_file(filename): - return filename.endswith(('.txt', ".yaml", ".zip")) - +from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file from Generate import roll_settings, PlandoOptions from Utils import parse_yamls @@ -30,7 +26,15 @@ def check(): 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") @@ -41,33 +45,42 @@ def mysterycheck(): def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} - for file in files: - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '': - return 'No selected file' - elif file.filename in options: - return f'Conflicting files named {file.filename} submitted' - elif file and allowed_file(file.filename): - if file.filename.endswith(".zip"): + for uploaded_file in files: + if banned_file(uploaded_file.filename): + return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. " + "Your file was deleted.") + # If the user does not select file, the browser will still submit an empty string without a file name. + elif 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_options(uploaded_file.filename): + if uploaded_file.filename.endswith(".zip"): + if not zipfile.is_zipfile(uploaded_file): + return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened." - with zipfile.ZipFile(file, 'r') as zfile: - infolist = zfile.infolist() + uploaded_file.seek(0) # offset from is_zipfile check + with zipfile.ZipFile(uploaded_file, "r") as zfile: + for file in zfile.infolist(): + # Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS). + base_filename = os.path.basename(file.filename) - 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")): + if base_filename.endswith(".archipelago"): + return Markup("Error: Your .zip file contains an .archipelago file. " + 'Did you mean to host a game?') + elif base_filename.endswith(".zip"): + return "Nested .zip files inside a .zip are not supported." + elif banned_file(base_filename): + return ("Uploaded data contained a rom file, which is likely to contain copyrighted " + "material. Your file was deleted.") + # Ignore dot-files. + elif not base_filename.startswith(".") and allowed_options(base_filename): options[file.filename] = zfile.open(file, "r").read() else: - options[file.filename] = file.read() + options[uploaded_file.filename] = uploaded_file.read() + if not options: - return "Did not find a .yaml file to process." + return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}" return options diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index fbe7b5f199..9048d51ff7 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -11,6 +11,7 @@ import socket import threading import time import typing +import sys import websockets from pony.orm import commit, db_session, select @@ -28,8 +29,10 @@ from .models import Command, GameDataPackage, Room, db class CustomClientMessageProcessor(ClientMessageProcessor): ctx: WebHostContext - def _cmd_video(self, platform, user): - """Set a link for your name in the WebHostLib tracker pointing to a video stream""" + def _cmd_video(self, platform: str, user: str): + """Set a link for your name in the WebHostLib tracker pointing to a video stream. + Currently, only YouTube and Twitch platforms are supported. + """ if platform.lower().startswith("t"): # twitch self.ctx.video[self.client.team, self.client.slot] = "Twitch", user self.ctx.save() @@ -203,8 +206,10 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, db.generate_mapping(check_tables=False) async def main(): - import gc + 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, game_ports) @@ -239,6 +244,12 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) await ctx.shutdown_task + + # ensure auto launch is on the same page in regard to room activity. + with db_session: + room: Room = Room.get(id=ctx.room_id) + room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60) + logging.info("Shutting down") with Locker(room_id): diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index 5cf503be1b..a09ca70171 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -90,6 +90,8 @@ def download_slot_file(room_id, player_id: int): fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json" elif slot_data.game == "Kingdom Hearts 2": fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip" + elif slot_data.game == "Final Fantasy Mystic Quest": + fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq" else: return "Game download not supported." return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname) diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index ddcc5ffb6c..ee1ce591ee 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -1,18 +1,18 @@ +import concurrent.futures import json import os import pickle import random import tempfile import zipfile -import concurrent.futures from collections import Counter -from typing import Dict, Optional, Any, Union, List +from typing import Any, Dict, List, Optional, Union -from flask import request, flash, redirect, url_for, session, render_template +from flask import flash, redirect, render_template, request, session, url_for from pony.orm import commit, db_session -from BaseClasses import seeddigits, get_seed -from Generate import handle_name, PlandoOptions +from BaseClasses import get_seed, seeddigits +from Generate import PlandoOptions, handle_name from Main import main as ERmain from Utils import __version__ from WebHostLib import app @@ -131,6 +131,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", {"bosses", "items", "connections", "texts"})) erargs.skip_prog_balancing = False + erargs.skip_output = False name_counter = Counter() for player, (playerfile, settings) in enumerate(gen_options.items(), 1): diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 6d3e82c00c..ee04e56fd7 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -32,29 +32,46 @@ 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') +# TODO for back compat. remove around 0.4.5 +@app.route("/weighted-settings") def weighted_settings(): - return render_template(f"weighted-settings.html") + return redirect("weighted-options", 301) -# Player settings pages -@app.route('/games//player-settings') -def player_settings(game): - return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) +@app.route("/weighted-options") +@cache.cached() +def weighted_options(): + return render_template("weighted-options.html") + + +# TODO for back compat. remove around 0.4.5 +@app.route("/games//player-settings") +def player_settings(game: str): + return redirect(url_for("player_options", game=game), 301) + + +# Player options pages +@app.route("/games//player-options") +@cache.cached() +def player_options(game: str): + return render_template("player-options.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 +81,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 +168,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,10 +188,11 @@ 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(): if not world.hidden: - has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page + has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page available_games.append({ 'title': game, 'has_settings': has_settings }) return render_template("siteMap.html", games=available_games) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index fca01407e0..0158de7e24 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -3,11 +3,8 @@ import logging import os import typing -import yaml -from jinja2 import Template - import Options -from Utils import __version__, local_path +from Utils import local_path from worlds.AutoWorld import AutoWorldRegister handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", @@ -25,10 +22,10 @@ def create(): return "Please document me!" return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip() - weighted_settings = { + weighted_options = { "baseOptions": { "description": "Generated by https://archipelago.gg/", - "name": "Player", + "name": "", "game": {}, }, "games": {}, @@ -36,17 +33,14 @@ def create(): 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 = { + # Generate JSON files for player-options pages + player_options = { "baseOptions": { "description": f"Generated by https://archipelago.gg/ for {game_name}", "game": game_name, - "name": "Player", + "name": "", }, } @@ -87,8 +81,8 @@ def create(): "max": option.range_end, } - if issubclass(option, Options.SpecialRange): - game_options[option_name]["type"] = 'special_range' + if issubclass(option, Options.NamedRange): + game_options[option_name]["type"] = 'named_range' game_options[option_name]["value_names"] = {} for key, val in option.special_range_names.items(): game_options[option_name]["value_names"][key] = val @@ -120,17 +114,53 @@ def create(): } else: - logging.debug(f"{option} not exported to Web Settings.") + logging.debug(f"{option} not exported to Web Options.") - player_settings["gameOptions"] = game_options + player_options["gameOptions"] = game_options - os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True) + player_options["presetOptions"] = {} + for preset_name, preset in world.web.options_presets.items(): + player_options["presetOptions"][preset_name] = {} + for option_name, option_value in preset.items(): + # Random range type settings are not valid. + assert (not str(option_value).startswith("random-")), \ + f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \ + f"values are not supported for presets." - with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f: - json.dump(player_settings, f, indent=2, separators=(',', ': ')) + # Normal random is supported, but needs to be handled explicitly. + if option_value == "random": + player_options["presetOptions"][preset_name][option_name] = option_value + continue - if not world.hidden and world.web.settings_page is True: - # Add the random option to Choice, TextChoice, and Toggle settings + option = world.options_dataclass.type_hints[option_name].from_any(option_value) + if isinstance(option, Options.NamedRange) and isinstance(option_value, str): + assert option_value in option.special_range_names, \ + f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \ + f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." + + # Still use the true value for the option, not the name. + player_options["presetOptions"][preset_name][option_name] = option.value + elif isinstance(option, Options.Range): + player_options["presetOptions"][preset_name][option_name] = option.value + elif isinstance(option_value, str): + # For Choice and Toggle options, the value should be the name of the option. This is to prevent + # setting a preset for an option with an overridden from_text method that would normally be okay, + # but would not be okay for the webhost's current implementation of player options UI. + assert option.name_lookup[option.value] == option_value, \ + f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \ + f"Values must not be resolved to a different option via option.from_text (or an alias)." + player_options["presetOptions"][preset_name][option_name] = option.current_key + else: + # int and bool values are fine, just resolve them to the current key for webhost. + player_options["presetOptions"][preset_name][option_name] = option.current_key + + os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True) + + with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f: + json.dump(player_options, f, indent=2, separators=(',', ': ')) + + if not world.hidden and world.web.options_page is True: + # Add the random option to Choice, TextChoice, and Toggle options for option in game_options.values(): if option["type"] == "select": option["options"].append({"name": "Random", "value": "random"}) @@ -138,11 +168,21 @@ def create(): if not option["defaultValue"]: option["defaultValue"] = "random" - weighted_settings["baseOptions"]["game"][game_name] = 0 - weighted_settings["games"][game_name] = {} - weighted_settings["games"][game_name]["gameSettings"] = game_options - weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names) - weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names) + weighted_options["baseOptions"]["game"][game_name] = 0 + weighted_options["games"][game_name] = { + "gameSettings": game_options, + "gameItems": tuple(world.item_names), + "gameItemGroups": [ + group for group in world.item_name_groups.keys() if group != "Everything" + ], + "gameItemDescriptions": world.item_descriptions, + "gameLocations": tuple(world.location_names), + "gameLocationGroups": [ + group for group in world.location_name_groups.keys() if group != "Everywhere" + ], + "gameLocationDescriptions": world.location_descriptions, + } + + with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f: + json.dump(weighted_options, f, indent=2, separators=(',', ': ')) - with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f: - json.dump(weighted_settings, f, indent=2, separators=(',', ': ')) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index a3695e3383..62707d78cf 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,10 +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-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' +bokeh>=3.3.2; python_version >= '3.9' markupsafe>=2.1.3 diff --git a/WebHostLib/static/assets/faq/faq_en.md b/WebHostLib/static/assets/faq/faq_en.md index 74f423df1f..fb1ccd2d6f 100644 --- a/WebHostLib/static/assets/faq/faq_en.md +++ b/WebHostLib/static/assets/faq/faq_en.md @@ -2,13 +2,62 @@ ## What is a randomizer? -A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A -normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized +A randomizer is a modification of a game which reorganizes the items required to progress through that game. A +normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized game, you might first find item C, then A, then B. -This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they -play a randomized game. Putting items in non-standard locations can require the player to think about the game world and -the items they encounter in new and interesting ways. +This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they +play. Putting items in non-standard locations can require the player to think about the game world and the items they +encounter in new and interesting ways. + +## What is a multiworld? + +While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a +two player multiworld, players A and B each get their own randomized version of a game, called a world. In each +player's game, they may find items which belong to the other player. If player A finds an item which belongs to +player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring +players to rely upon each other to complete their game. + +## What does multi-game mean? + +While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows +players to randomize any of the supported games, and send items between them. This allows players of different +games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld. +Here is a list of our [Supported Games](https://archipelago.gg/games). + +## Can I generate a single-player game with Archipelago? + +Yes. All of our supported games can be generated as single-player experiences both on the website and by installing +the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to +play, open the Settings Page, pick your settings, and click Generate Game. + +## How do I get started? + +We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the +software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for +including multiple games, and hosting multiworlds on the website for ease and convenience. + +If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join +our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer +any questions you might have. + +## What are some common terms I should know? + +As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used +by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be +found in the [Glossary](/glossary/en). + +## Does everyone need to be connected at the same time? + +There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either +be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"), +where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how +you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating +their multiworld. + +If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items +in that game belonging to other players are sent out automatically. This allows other players to continue to play +uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en). ## What happens if an item is placed somewhere it is impossible to get? @@ -17,53 +66,15 @@ is to ensure items necessary to complete the game will be accessible to the play rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are comfortable exploiting certain glitches in the game. -## What is a multi-world? - -While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a -two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's -game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the -item will be sent to player B's world over the internet. - -This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete -their game. - -## What happens if a person has to leave early? - -If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the -items in that game which belong to other players are sent out automatically, so other players can continue to play. - -## What does multi-game mean? - -While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows -players to randomize any of a number of supported games, and send items between them. This allows players of different -games to interact with one another in a single multiplayer environment. - -## Can I generate a single-player game with Archipelago? - -Yes. All our supported games can be generated as single-player experiences, and so long as you download the software, -the website is not required to generate them. - -## How do I get started? - -If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join -our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer -any questions you might have. - -## What are some common terms I should know? - -As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms -and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common -to Archipelago and its specific systems please see the [Glossary](/glossary/en). - ## I want to add a game to the Archipelago randomizer. How do I do that? -The best way to get started is to take a look at our code on GitHub -at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago). +The best way to get started is to take a look at our code on GitHub: +[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago). -There you will find examples of games in the worlds folder -at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds). +There, you will find examples of games in the `worlds` folder: +[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds). -You may also find developer documentation in the docs folder -at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). +You may also find developer documentation in the `docs` folder: +[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord. diff --git a/WebHostLib/static/assets/player-options.js b/WebHostLib/static/assets/player-options.js new file mode 100644 index 0000000000..92cd6c43f3 --- /dev/null +++ b/WebHostLib/static/assets/player-options.js @@ -0,0 +1,523 @@ +let gameName = null; + +window.addEventListener('load', () => { + gameName = document.getElementById('player-options').getAttribute('data-game'); + + // Update game name on page + document.getElementById('game-name').innerText = gameName; + + fetchOptionData().then((results) => { + let optionHash = localStorage.getItem(`${gameName}-hash`); + if (!optionHash) { + // If no hash data has been set before, set it now + optionHash = md5(JSON.stringify(results)); + localStorage.setItem(`${gameName}-hash`, optionHash); + localStorage.removeItem(gameName); + } + + if (optionHash !== md5(JSON.stringify(results))) { + showUserMessage( + 'Your options are out of date! Click here to update them! Be aware this will reset them all to default.' + ); + document.getElementById('user-message').addEventListener('click', resetOptions); + } + + // Page setup + createDefaultOptions(results); + buildUI(results); + adjustHeaderWidth(); + + // Event listeners + document.getElementById('export-options').addEventListener('click', () => exportOptions()); + document.getElementById('generate-race').addEventListener('click', () => generateGame(true)); + document.getElementById('generate-game').addEventListener('click', () => generateGame()); + + // Name input field + const playerOptions = JSON.parse(localStorage.getItem(gameName)); + const nameInput = document.getElementById('player-name'); + nameInput.addEventListener('keyup', (event) => updateBaseOption(event)); + nameInput.value = playerOptions.name; + + // Presets + const presetSelect = document.getElementById('game-options-preset'); + presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value)); + for (const preset in results['presetOptions']) { + const presetOption = document.createElement('option'); + presetOption.innerText = preset; + presetSelect.appendChild(presetOption); + } + presetSelect.value = localStorage.getItem(`${gameName}-preset`); + results['presetOptions']['__default'] = {}; + }).catch((e) => { + console.error(e); + const url = new URL(window.location.href); + window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`); + }) +}); + +const resetOptions = () => { + localStorage.removeItem(gameName); + localStorage.removeItem(`${gameName}-hash`); + localStorage.removeItem(`${gameName}-preset`); + window.location.reload(); +}; + +const fetchOptionData = () => new Promise((resolve, reject) => { + const ajax = new XMLHttpRequest(); + ajax.onreadystatechange = () => { + if (ajax.readyState !== 4) { return; } + if (ajax.status !== 200) { + reject(ajax.responseText); + return; + } + try{ resolve(JSON.parse(ajax.responseText)); } + catch(error){ reject(error); } + }; + ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true); + ajax.send(); +}); + +const createDefaultOptions = (optionData) => { + if (!localStorage.getItem(gameName)) { + const newOptions = { + [gameName]: {}, + }; + for (let baseOption of Object.keys(optionData.baseOptions)){ + newOptions[baseOption] = optionData.baseOptions[baseOption]; + } + for (let gameOption of Object.keys(optionData.gameOptions)){ + newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue; + } + localStorage.setItem(gameName, JSON.stringify(newOptions)); + } + + if (!localStorage.getItem(`${gameName}-preset`)) { + localStorage.setItem(`${gameName}-preset`, '__default'); + } +}; + +const buildUI = (optionData) => { + // Game Options + const leftGameOpts = {}; + const rightGameOpts = {}; + Object.keys(optionData.gameOptions).forEach((key, index) => { + if (index < Object.keys(optionData.gameOptions).length / 2) { + leftGameOpts[key] = optionData.gameOptions[key]; + } else { + rightGameOpts[key] = optionData.gameOptions[key]; + } + }); + document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts)); + document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts)); +}; + +const buildOptionsTable = (options, romOpts = false) => { + const currentOptions = JSON.parse(localStorage.getItem(gameName)); + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + + Object.keys(options).forEach((option) => { + const tr = document.createElement('tr'); + + // td Left + const tdl = document.createElement('td'); + const label = document.createElement('label'); + label.textContent = `${options[option].displayName}: `; + label.setAttribute('for', option); + + const questionSpan = document.createElement('span'); + questionSpan.classList.add('interactive'); + questionSpan.setAttribute('data-tooltip', options[option].description); + questionSpan.innerText = '(?)'; + + label.appendChild(questionSpan); + tdl.appendChild(label); + tr.appendChild(tdl); + + // td Right + const tdr = document.createElement('td'); + let element = null; + + const randomButton = document.createElement('button'); + + switch(options[option].type) { + case 'select': + element = document.createElement('div'); + element.classList.add('select-container'); + let select = document.createElement('select'); + select.setAttribute('id', option); + select.setAttribute('data-key', option); + if (romOpts) { select.setAttribute('data-romOpt', '1'); } + options[option].options.forEach((opt) => { + const optionElement = document.createElement('option'); + optionElement.setAttribute('value', opt.value); + optionElement.innerText = opt.name; + + if ((isNaN(currentOptions[gameName][option]) && + (parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) || + (opt.value === currentOptions[gameName][option])) + { + optionElement.selected = true; + } + select.appendChild(optionElement); + }); + select.addEventListener('change', (event) => updateGameOption(event.target)); + element.appendChild(select); + + // Randomize button + randomButton.innerText = '🎲'; + randomButton.classList.add('randomize-button'); + randomButton.setAttribute('data-key', option); + randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); + randomButton.addEventListener('click', (event) => toggleRandomize(event, select)); + if (currentOptions[gameName][option] === 'random') { + randomButton.classList.add('active'); + select.disabled = true; + } + + element.appendChild(randomButton); + break; + + case 'range': + element = document.createElement('div'); + element.classList.add('range-container'); + + let range = document.createElement('input'); + range.setAttribute('id', option); + range.setAttribute('type', 'range'); + range.setAttribute('data-key', option); + range.setAttribute('min', options[option].min); + range.setAttribute('max', options[option].max); + range.value = currentOptions[gameName][option]; + range.addEventListener('change', (event) => { + document.getElementById(`${option}-value`).innerText = event.target.value; + updateGameOption(event.target); + }); + element.appendChild(range); + + let rangeVal = document.createElement('span'); + rangeVal.classList.add('range-value'); + rangeVal.setAttribute('id', `${option}-value`); + rangeVal.innerText = currentOptions[gameName][option] !== 'random' ? + currentOptions[gameName][option] : options[option].defaultValue; + element.appendChild(rangeVal); + + // Randomize button + randomButton.innerText = '🎲'; + randomButton.classList.add('randomize-button'); + randomButton.setAttribute('data-key', option); + randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); + randomButton.addEventListener('click', (event) => toggleRandomize(event, range)); + if (currentOptions[gameName][option] === 'random') { + randomButton.classList.add('active'); + range.disabled = true; + } + + element.appendChild(randomButton); + break; + + case 'named_range': + element = document.createElement('div'); + element.classList.add('named-range-container'); + + // Build the select element + let namedRangeSelect = document.createElement('select'); + namedRangeSelect.setAttribute('data-key', option); + Object.keys(options[option].value_names).forEach((presetName) => { + let presetOption = document.createElement('option'); + presetOption.innerText = presetName; + presetOption.value = options[option].value_names[presetName]; + const words = presetOption.innerText.split('_'); + for (let i = 0; i < words.length; i++) { + words[i] = words[i][0].toUpperCase() + words[i].substring(1); + } + presetOption.innerText = words.join(' '); + namedRangeSelect.appendChild(presetOption); + }); + let customOption = document.createElement('option'); + customOption.innerText = 'Custom'; + customOption.value = 'custom'; + customOption.selected = true; + namedRangeSelect.appendChild(customOption); + if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) { + namedRangeSelect.value = Number(currentOptions[gameName][option]); + } + + // Build range element + let namedRangeWrapper = document.createElement('div'); + namedRangeWrapper.classList.add('named-range-wrapper'); + let namedRange = document.createElement('input'); + namedRange.setAttribute('type', 'range'); + namedRange.setAttribute('data-key', option); + namedRange.setAttribute('min', options[option].min); + namedRange.setAttribute('max', options[option].max); + namedRange.value = currentOptions[gameName][option]; + + // Build rage value element + let namedRangeVal = document.createElement('span'); + namedRangeVal.classList.add('range-value'); + namedRangeVal.setAttribute('id', `${option}-value`); + namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ? + currentOptions[gameName][option] : options[option].defaultValue; + + // Configure select event listener + namedRangeSelect.addEventListener('change', (event) => { + if (event.target.value === 'custom') { return; } + + // Update range slider + namedRange.value = event.target.value; + document.getElementById(`${option}-value`).innerText = event.target.value; + updateGameOption(event.target); + }); + + // Configure range event handler + namedRange.addEventListener('change', (event) => { + // Update select element + namedRangeSelect.value = + (Object.values(options[option].value_names).includes(parseInt(event.target.value))) ? + parseInt(event.target.value) : 'custom'; + document.getElementById(`${option}-value`).innerText = event.target.value; + updateGameOption(event.target); + }); + + element.appendChild(namedRangeSelect); + namedRangeWrapper.appendChild(namedRange); + namedRangeWrapper.appendChild(namedRangeVal); + element.appendChild(namedRangeWrapper); + + // Randomize button + randomButton.innerText = '🎲'; + randomButton.classList.add('randomize-button'); + randomButton.setAttribute('data-key', option); + randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); + randomButton.addEventListener('click', (event) => toggleRandomize( + event, namedRange, namedRangeSelect) + ); + if (currentOptions[gameName][option] === 'random') { + randomButton.classList.add('active'); + namedRange.disabled = true; + namedRangeSelect.disabled = true; + } + + namedRangeWrapper.appendChild(randomButton); + break; + + default: + console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`); + return; + } + + tdr.appendChild(element); + tr.appendChild(tdr); + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + return table; +}; + +const setPresets = (optionsData, presetName) => { + const defaults = optionsData['gameOptions']; + const preset = optionsData['presetOptions'][presetName]; + + localStorage.setItem(`${gameName}-preset`, presetName); + + if (!preset) { + console.error(`No presets defined for preset name: '${presetName}'`); + return; + } + + const updateOptionElement = (option, presetValue) => { + const optionElement = document.querySelector(`#${option}[data-key='${option}']`); + const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`); + + if (presetValue === 'random') { + randomElement.classList.add('active'); + optionElement.disabled = true; + updateGameOption(randomElement, false); + } else { + optionElement.value = presetValue; + randomElement.classList.remove('active'); + optionElement.disabled = undefined; + updateGameOption(optionElement, false); + } + }; + + for (const option in defaults) { + let presetValue = preset[option]; + if (presetValue === undefined) { + // Using the default value if not set in presets. + presetValue = defaults[option]['defaultValue']; + } + + switch (defaults[option].type) { + case 'range': + const numberElement = document.querySelector(`#${option}-value`); + if (presetValue === 'random') { + numberElement.innerText = defaults[option]['defaultValue'] === 'random' + ? defaults[option]['min'] // A fallback so we don't print 'random' in the UI. + : defaults[option]['defaultValue']; + } else { + numberElement.innerText = presetValue; + } + + updateOptionElement(option, presetValue); + break; + + case 'select': { + updateOptionElement(option, presetValue); + break; + } + + case 'named_range': { + const selectElement = document.querySelector(`select[data-key='${option}']`); + const rangeElement = document.querySelector(`input[data-key='${option}']`); + const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`); + + if (presetValue === 'random') { + randomElement.classList.add('active'); + selectElement.disabled = true; + rangeElement.disabled = true; + updateGameOption(randomElement, false); + } else { + rangeElement.value = presetValue; + selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ? + parseInt(presetValue) : 'custom'; + document.getElementById(`${option}-value`).innerText = presetValue; + + randomElement.classList.remove('active'); + selectElement.disabled = undefined; + rangeElement.disabled = undefined; + updateGameOption(rangeElement, false); + } + break; + } + + default: + console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`); + break; + } + } +}; + +const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { + const active = event.target.classList.contains('active'); + const randomButton = event.target; + + if (active) { + randomButton.classList.remove('active'); + inputElement.disabled = undefined; + if (optionalSelectElement) { + optionalSelectElement.disabled = undefined; + } + } else { + randomButton.classList.add('active'); + inputElement.disabled = true; + if (optionalSelectElement) { + optionalSelectElement.disabled = true; + } + } + updateGameOption(active ? inputElement : randomButton); +}; + +const updateBaseOption = (event) => { + const options = JSON.parse(localStorage.getItem(gameName)); + options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? + event.target.value : parseInt(event.target.value); + localStorage.setItem(gameName, JSON.stringify(options)); +}; + +const updateGameOption = (optionElement, toggleCustomPreset = true) => { + const options = JSON.parse(localStorage.getItem(gameName)); + + if (toggleCustomPreset) { + localStorage.setItem(`${gameName}-preset`, '__custom'); + const presetElement = document.getElementById('game-options-preset'); + presetElement.value = '__custom'; + } + + if (optionElement.classList.contains('randomize-button')) { + // If the event passed in is the randomize button, then we know what we must do. + options[gameName][optionElement.getAttribute('data-key')] = 'random'; + } else { + options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ? + optionElement.value : parseInt(optionElement.value, 10); + } + + localStorage.setItem(gameName, JSON.stringify(options)); +}; + +const exportOptions = () => { + const options = JSON.parse(localStorage.getItem(gameName)); + const preset = localStorage.getItem(`${gameName}-preset`); + switch (preset) { + case '__default': + options['description'] = `Generated by https://archipelago.gg with the default preset.`; + break; + + case '__custom': + options['description'] = `Generated by https://archipelago.gg.`; + break; + + default: + options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`; + } + + if (!options.name || options.name.toString().trim().length === 0) { + return showUserMessage('You must enter a player name!'); + } + const yamlText = jsyaml.safeDump(options, { 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) => { + const downloadLink = document.createElement('a'); + downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text)) + downloadLink.setAttribute('download', filename); + downloadLink.style.display = 'none'; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +}; + +const generateGame = (raceMode = false) => { + const options = JSON.parse(localStorage.getItem(gameName)); + if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) { + return showUserMessage('You must enter a player name!'); + } + + axios.post('/api/generate', { + weights: { player: options }, + presetData: { player: options }, + playerCount: 1, + spoiler: 3, + race: raceMode ? '1' : '0', + }).then((response) => { + window.location.href = response.data.url; + }).catch((error) => { + let userMessage = 'Something went wrong and your game could not be generated.'; + if (error.response.data.text) { + userMessage += ' ' + error.response.data.text; + } + showUserMessage(userMessage); + console.error(error); + }); +}; + +const showUserMessage = (message) => { + const userMessage = document.getElementById('user-message'); + userMessage.innerText = message; + userMessage.classList.add('visible'); + window.scrollTo(0, 0); + userMessage.addEventListener('click', () => { + userMessage.classList.remove('visible'); + userMessage.addEventListener('click', hideUserMessage); + }); +}; + +const hideUserMessage = () => { + const userMessage = document.getElementById('user-message'); + userMessage.classList.remove('visible'); + userMessage.removeEventListener('click', hideUserMessage); +}; diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js deleted file mode 100644 index f75ba90603..0000000000 --- a/WebHostLib/static/assets/player-settings.js +++ /dev/null @@ -1,398 +0,0 @@ -let gameName = null; - -window.addEventListener('load', () => { - gameName = document.getElementById('player-settings').getAttribute('data-game'); - - // Update game name on page - document.getElementById('game-name').innerText = gameName; - - fetchSettingData().then((results) => { - let settingHash = localStorage.getItem(`${gameName}-hash`); - if (!settingHash) { - // If no hash data has been set before, set it now - settingHash = md5(JSON.stringify(results)); - localStorage.setItem(`${gameName}-hash`, settingHash); - localStorage.removeItem(gameName); - } - - if (settingHash !== md5(JSON.stringify(results))) { - showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " + - "them all to default."); - document.getElementById('user-message').addEventListener('click', resetSettings); - } - - // Page setup - createDefaultSettings(results); - buildUI(results); - 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()); - - // Name input field - const playerSettings = JSON.parse(localStorage.getItem(gameName)); - const nameInput = document.getElementById('player-name'); - nameInput.addEventListener('keyup', (event) => updateBaseSetting(event)); - nameInput.value = playerSettings.name; - }).catch((e) => { - console.error(e); - const url = new URL(window.location.href); - window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`); - }) -}); - -const resetSettings = () => { - localStorage.removeItem(gameName); - localStorage.removeItem(`${gameName}-hash`) - window.location.reload(); -}; - -const fetchSettingData = () => new Promise((resolve, reject) => { - const ajax = new XMLHttpRequest(); - ajax.onreadystatechange = () => { - if (ajax.readyState !== 4) { return; } - if (ajax.status !== 200) { - reject(ajax.responseText); - return; - } - try{ resolve(JSON.parse(ajax.responseText)); } - catch(error){ reject(error); } - }; - ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true); - ajax.send(); -}); - -const createDefaultSettings = (settingData) => { - if (!localStorage.getItem(gameName)) { - const newSettings = { - [gameName]: {}, - }; - for (let baseOption of Object.keys(settingData.baseOptions)){ - newSettings[baseOption] = settingData.baseOptions[baseOption]; - } - for (let gameOption of Object.keys(settingData.gameOptions)){ - newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue; - } - localStorage.setItem(gameName, JSON.stringify(newSettings)); - } -}; - -const buildUI = (settingData) => { - // Game Options - const leftGameOpts = {}; - const rightGameOpts = {}; - Object.keys(settingData.gameOptions).forEach((key, index) => { - if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; } - else { rightGameOpts[key] = settingData.gameOptions[key]; } - }); - document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts)); - document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts)); -}; - -const buildOptionsTable = (settings, romOpts = false) => { - const currentSettings = JSON.parse(localStorage.getItem(gameName)); - const table = document.createElement('table'); - const tbody = document.createElement('tbody'); - - Object.keys(settings).forEach((setting) => { - const tr = document.createElement('tr'); - - // td Left - const tdl = document.createElement('td'); - const label = document.createElement('label'); - label.textContent = `${settings[setting].displayName}: `; - label.setAttribute('for', setting); - - const questionSpan = document.createElement('span'); - questionSpan.classList.add('interactive'); - questionSpan.setAttribute('data-tooltip', settings[setting].description); - questionSpan.innerText = '(?)'; - - label.appendChild(questionSpan); - tdl.appendChild(label); - tr.appendChild(tdl); - - // td Right - const tdr = document.createElement('td'); - let element = null; - - const randomButton = document.createElement('button'); - - switch(settings[setting].type){ - case 'select': - element = document.createElement('div'); - element.classList.add('select-container'); - let select = document.createElement('select'); - select.setAttribute('id', setting); - select.setAttribute('data-key', setting); - if (romOpts) { select.setAttribute('data-romOpt', '1'); } - settings[setting].options.forEach((opt) => { - const option = document.createElement('option'); - option.setAttribute('value', opt.value); - option.innerText = opt.name; - if ((isNaN(currentSettings[gameName][setting]) && - (parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) || - (opt.value === currentSettings[gameName][setting])) - { - option.selected = true; - } - select.appendChild(option); - }); - select.addEventListener('change', (event) => updateGameSetting(event.target)); - element.appendChild(select); - - // Randomize button - randomButton.innerText = '🎲'; - randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', setting); - randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, select)); - if (currentSettings[gameName][setting] === 'random') { - randomButton.classList.add('active'); - select.disabled = true; - } - - element.appendChild(randomButton); - break; - - case 'range': - element = document.createElement('div'); - element.classList.add('range-container'); - - let range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('data-key', setting); - range.setAttribute('min', settings[setting].min); - range.setAttribute('max', settings[setting].max); - range.value = currentSettings[gameName][setting]; - range.addEventListener('change', (event) => { - document.getElementById(`${setting}-value`).innerText = event.target.value; - updateGameSetting(event.target); - }); - element.appendChild(range); - - let rangeVal = document.createElement('span'); - rangeVal.classList.add('range-value'); - rangeVal.setAttribute('id', `${setting}-value`); - rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? - currentSettings[gameName][setting] : settings[setting].defaultValue; - element.appendChild(rangeVal); - - // Randomize button - randomButton.innerText = '🎲'; - randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', setting); - randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, range)); - if (currentSettings[gameName][setting] === 'random') { - randomButton.classList.add('active'); - range.disabled = true; - } - - element.appendChild(randomButton); - break; - - case 'special_range': - element = document.createElement('div'); - element.classList.add('special-range-container'); - - // Build the select element - let specialRangeSelect = document.createElement('select'); - specialRangeSelect.setAttribute('data-key', setting); - Object.keys(settings[setting].value_names).forEach((presetName) => { - let presetOption = document.createElement('option'); - presetOption.innerText = presetName; - presetOption.value = settings[setting].value_names[presetName]; - const words = presetOption.innerText.split("_"); - for (let i = 0; i < words.length; i++) { - words[i] = words[i][0].toUpperCase() + words[i].substring(1); - } - presetOption.innerText = words.join(" "); - specialRangeSelect.appendChild(presetOption); - }); - let customOption = document.createElement('option'); - customOption.innerText = 'Custom'; - customOption.value = 'custom'; - customOption.selected = true; - specialRangeSelect.appendChild(customOption); - if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) { - specialRangeSelect.value = Number(currentSettings[gameName][setting]); - } - - // Build range element - let specialRangeWrapper = document.createElement('div'); - specialRangeWrapper.classList.add('special-range-wrapper'); - let specialRange = document.createElement('input'); - specialRange.setAttribute('type', 'range'); - specialRange.setAttribute('data-key', setting); - specialRange.setAttribute('min', settings[setting].min); - specialRange.setAttribute('max', settings[setting].max); - specialRange.value = currentSettings[gameName][setting]; - - // Build rage value element - let specialRangeVal = document.createElement('span'); - specialRangeVal.classList.add('range-value'); - specialRangeVal.setAttribute('id', `${setting}-value`); - specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? - currentSettings[gameName][setting] : settings[setting].defaultValue; - - // Configure select event listener - specialRangeSelect.addEventListener('change', (event) => { - if (event.target.value === 'custom') { return; } - - // Update range slider - specialRange.value = event.target.value; - document.getElementById(`${setting}-value`).innerText = event.target.value; - updateGameSetting(event.target); - }); - - // Configure range event handler - specialRange.addEventListener('change', (event) => { - // Update select element - specialRangeSelect.value = - (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ? - parseInt(event.target.value) : 'custom'; - document.getElementById(`${setting}-value`).innerText = event.target.value; - updateGameSetting(event.target); - }); - - element.appendChild(specialRangeSelect); - specialRangeWrapper.appendChild(specialRange); - specialRangeWrapper.appendChild(specialRangeVal); - element.appendChild(specialRangeWrapper); - - // Randomize button - randomButton.innerText = '🎲'; - randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', setting); - randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize( - event, specialRange, specialRangeSelect) - ); - if (currentSettings[gameName][setting] === 'random') { - randomButton.classList.add('active'); - specialRange.disabled = true; - specialRangeSelect.disabled = true; - } - - specialRangeWrapper.appendChild(randomButton); - break; - - default: - console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`); - return; - } - - tdr.appendChild(element); - tr.appendChild(tdr); - tbody.appendChild(tr); - }); - - table.appendChild(tbody); - return table; -}; - -const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { - const active = event.target.classList.contains('active'); - const randomButton = event.target; - - if (active) { - randomButton.classList.remove('active'); - inputElement.disabled = undefined; - if (optionalSelectElement) { - optionalSelectElement.disabled = undefined; - } - } else { - randomButton.classList.add('active'); - inputElement.disabled = true; - if (optionalSelectElement) { - optionalSelectElement.disabled = true; - } - } - - updateGameSetting(randomButton); -}; - -const updateBaseSetting = (event) => { - const options = JSON.parse(localStorage.getItem(gameName)); - options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? - event.target.value : parseInt(event.target.value); - localStorage.setItem(gameName, JSON.stringify(options)); -}; - -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'; - } else { - options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ? - settingElement.value : parseInt(settingElement.value, 10); - } - - localStorage.setItem(gameName, JSON.stringify(options)); -}; - -const exportSettings = () => { - const settings = JSON.parse(localStorage.getItem(gameName)); - if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) { - return showUserMessage('You must enter a player name!'); - } - 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) => { - const downloadLink = document.createElement('a'); - downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text)) - downloadLink.setAttribute('download', filename); - downloadLink.style.display = 'none'; - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); -}; - -const generateGame = (raceMode = false) => { - const settings = JSON.parse(localStorage.getItem(gameName)); - if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) { - return showUserMessage('You must enter a player name!'); - } - - axios.post('/api/generate', { - weights: { player: settings }, - presetData: { player: settings }, - playerCount: 1, - spoiler: 3, - race: raceMode ? '1' : '0', - }).then((response) => { - window.location.href = response.data.url; - }).catch((error) => { - let userMessage = 'Something went wrong and your game could not be generated.'; - if (error.response.data.text) { - userMessage += ' ' + error.response.data.text; - } - showUserMessage(userMessage); - console.error(error); - }); -}; - -const showUserMessage = (message) => { - const userMessage = document.getElementById('user-message'); - userMessage.innerText = message; - userMessage.classList.add('visible'); - window.scrollTo(0, 0); - userMessage.addEventListener('click', () => { - userMessage.classList.remove('visible'); - userMessage.addEventListener('click', hideUserMessage); - }); -}; - -const hideUserMessage = () => { - const userMessage = document.getElementById('user-message'); - userMessage.classList.remove('visible'); - userMessage.removeEventListener('click', hideUserMessage); -}; diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js index 1acf0e0cc5..56eb15b5e5 100644 --- a/WebHostLib/static/assets/supportedGames.js +++ b/WebHostLib/static/assets/supportedGames.js @@ -1,51 +1,32 @@ window.addEventListener('load', () => { - const gameHeaders = document.getElementsByClassName('collapse-toggle'); - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - header.addEventListener('click', () => { - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); - if (gameInfo.classList.contains('collapsed')) { - gameArrow.innerText = '▼'; - gameInfo.classList.remove('collapsed'); - } else { - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); - } - }); - }); + // 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 Array.from(gameHeaders).forEach((header) => { + return toggleButtons.forEach((header) => { header.style.display = null; - const gameName = header.getAttribute('data-game'); - document.getElementById(`${gameName}-arrow`).innerText = '▶'; - document.getElementById(gameName).classList.add('collapsed'); + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); }); } // Loop over all the games - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); - + toggleButtons.forEach((header) => { // If the game name includes the search string, display the game. If not, hide it - if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) { + if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) { header.style.display = null; - gameArrow.innerText = '▼'; - gameInfo.classList.remove('collapsed'); + header.firstElementChild.innerText = '▼'; + header.nextElementSibling.classList.remove('collapsed'); } else { - console.log(header); header.style.display = 'none'; - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); + header.firstElementChild.innerText = '▶'; + header.nextElementSibling.classList.add('collapsed'); } }); }); @@ -54,30 +35,30 @@ window.addEventListener('load', () => { document.getElementById('collapse-all').addEventListener('click', collapseAll); }); -const expandAll = () => { - const gameHeaders = document.getElementsByClassName('collapse-toggle'); - // Loop over all the games - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); +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'); + } +}; - if (header.style.display === 'none') { return; } - gameArrow.innerText = '▼'; - gameInfo.classList.remove('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 = () => { - const gameHeaders = document.getElementsByClassName('collapse-toggle'); - // Loop over all the games - Array.from(gameHeaders).forEach((header) => { - const gameName = header.getAttribute('data-game'); - const gameArrow = document.getElementById(`${gameName}-arrow`); - const gameInfo = document.getElementById(gameName); - - if (header.style.display === 'none') { return; } - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); - }); + 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/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js index 41c4020dac..b8e089ece5 100644 --- a/WebHostLib/static/assets/trackerCommon.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -4,13 +4,20 @@ const adjustTableHeight = () => { return; const upperDistance = tablesContainer.getBoundingClientRect().top; - const containerHeight = window.innerHeight - upperDistance; - tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`; - const tableWrappers = document.getElementsByClassName('table-wrapper'); - for(let i=0; i < tableWrappers.length; i++){ - const maxHeight = (window.innerHeight - upperDistance) / 2; - tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`; + for (let i = 0; i < tableWrappers.length; i++) { + // Ensure we are starting from maximum size prior to calculation. + tableWrappers[i].style.height = null; + tableWrappers[i].style.maxHeight = null; + + // Set as a reasonable height, but still allows the user to resize element if they desire. + const currentHeight = tableWrappers[i].offsetHeight; + const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4); + if (currentHeight > maxHeight) { + tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`; + } + + tableWrappers[i].style.maxHeight = `${currentHeight}px`; } }; @@ -55,7 +62,7 @@ window.addEventListener('load', () => { render: function (data, type, row) { if (type === "sort" || type === 'type') { if (data === "None") - return -1; + return Number.MAX_VALUE; return parseInt(data); } diff --git a/WebHostLib/static/assets/weighted-options.js b/WebHostLib/static/assets/weighted-options.js new file mode 100644 index 0000000000..80f8efd1d7 --- /dev/null +++ b/WebHostLib/static/assets/weighted-options.js @@ -0,0 +1,1190 @@ +window.addEventListener('load', () => { + 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(data)); + localStorage.setItem('weighted-settings-hash', settingHash); + localStorage.removeItem('weighted-settings'); + } + + 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."; + userMessage.classList.add('visible'); + userMessage.addEventListener('click', resetSettings); + } + + // Page setup + const settings = new WeightedSettings(data); + settings.buildUI(); + settings.updateVisibleGames(); + adjustHeaderWidth(); + + // Event listeners + document.getElementById('export-options').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 nameInput = document.getElementById('player-name'); + nameInput.setAttribute('data-type', 'data'); + nameInput.setAttribute('data-setting', 'name'); + nameInput.addEventListener('keyup', (evt) => settings.updateBaseSetting(evt)); + nameInput.value = settings.current.name; + }); +}); + +const resetSettings = () => { + localStorage.removeItem('weighted-settings'); + localStorage.removeItem('weighted-settings-hash') + window.location.reload(); +}; + +const fetchSettingData = () => new Promise((resolve, reject) => { + fetch(new Request(`${window.location.origin}/static/generated/weighted-options.json`)).then((response) => { + try{ response.json().then((jsonObj) => resolve(jsonObj)); } + catch(error){ reject(error); } + }); +}); + +/// 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(this.data.baseOptions)){ + this.current[baseOption] = this.data.baseOptions[baseOption]; + } + + // Set options per game + for (let game of Object.keys(this.data.games)) { + // Initialize game object + this.current[game] = {}; + + // Transfer game settings + for (let gameSetting of Object.keys(this.data.games[game].gameSettings)){ + this.current[game][gameSetting] = {}; + + const setting = this.data.games[game].gameSettings[gameSetting]; + switch(setting.type){ + case 'select': + setting.options.forEach((option) => { + this.current[game][gameSetting][option.value] = + (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0; + }); + break; + case 'range': + case 'named_range': + this.current[game][gameSetting]['random'] = 0; + this.current[game][gameSetting]['random-low'] = 0; + this.current[game][gameSetting]['random-middle'] = 0; + this.current[game][gameSetting]['random-high'] = 0; + if (setting.hasOwnProperty('defaultValue')) { + this.current[game][gameSetting][setting.defaultValue] = 25; + } else { + this.current[game][gameSetting][setting.min] = 25; + } + break; + + case 'items-list': + case 'locations-list': + case 'custom-list': + this.current[game][gameSetting] = setting.defaultValue; + break; + + default: + console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`); + } + } + + 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 = []; + } + + this.save(); + } + + // 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.toString().trim().length === 0 || + settings.name.toString().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; + + // 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', `${this.name}-div`); + gameDiv.classList.add('game-div'); + gameDiv.classList.add('invisible'); + + const gameHeader = document.createElement('h2'); + gameHeader.innerText = this.name; + gameDiv.appendChild(gameHeader); + + const collapseButton = document.createElement('a'); + collapseButton.innerText = '(Collapse)'; + gameDiv.appendChild(collapseButton); + + const expandButton = document.createElement('a'); + expandButton.innerText = '(Expand)'; + expandButton.classList.add('invisible'); + gameDiv.appendChild(expandButton); + + // Sort items and locations alphabetically. + this.data.gameItems.sort(); + this.data.gameLocations.sort(); + + const weightedSettingsDiv = this.#buildWeightedSettingsDiv(); + gameDiv.appendChild(weightedSettingsDiv); + + const itemPoolDiv = this.#buildItemPoolDiv(); + gameDiv.appendChild(itemPoolDiv); + + const hintsDiv = this.#buildHintsDiv(); + gameDiv.appendChild(hintsDiv); + + const locationsDiv = this.#buildPriorityExclusionDiv(); + gameDiv.appendChild(locationsDiv); + + 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'); + }); + + expandButton.addEventListener('click', () => { + collapseButton.classList.remove('invisible'); + weightedSettingsDiv.classList.remove('invisible'); + itemPoolDiv.classList.remove('invisible'); + hintsDiv.classList.remove('invisible'); + locationsDiv.classList.remove('invisible'); + expandButton.classList.add('invisible'); + }); + + return gameDiv; + } + + #buildWeightedSettingsDiv() { + const settingsWrapper = document.createElement('div'); + settingsWrapper.classList.add('settings-wrapper'); + + Object.keys(this.data.gameSettings).forEach((settingName) => { + const setting = this.data.gameSettings[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', this.name); + 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', (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', `${this.name}-${settingName}-${option.value}`); + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); + + tbody.appendChild(tr); + }); + + optionTable.appendChild(tbody); + settingWrapper.appendChild(optionTable); + break; + + case 'range': + case 'named_range': + const rangeTable = document.createElement('table'); + const rangeTbody = document.createElement('tbody'); + + 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.

Accepted values:
` + + `Normal range: ${setting.min} - ${setting.max}`; + + const acceptedValuesOutsideRange = []; + if (setting.hasOwnProperty('value_names')) { + Object.keys(setting.value_names).forEach((specialName) => { + if ( + (setting.value_names[specialName] < setting.min) || + (setting.value_names[specialName] > setting.max) + ) { + hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; + acceptedValuesOutsideRange.push(setting.value_names[specialName]); + } + }); + + 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', `${this.name}-${settingName}-option`); + let placeholderText = `${setting.min} - ${setting.max}`; + acceptedValuesOutsideRange.forEach((aVal) => placeholderText += `, ${aVal}`); + optionInput.setAttribute('placeholder', placeholderText); + 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')); } + }); + + addOptionButton.addEventListener('click', () => { + const optionInput = document.getElementById(`${this.name}-${settingName}-option`); + let option = optionInput.value; + if (!option || !option.trim()) { return; } + option = parseInt(option, 10); + + let optionAcceptable = false; + if ((option >= setting.min) && (option <= setting.max)) { + optionAcceptable = true; + } + if (setting.hasOwnProperty('value_names') && Object.values(setting.value_names).includes(option)){ + optionAcceptable = true; + } + if (!optionAcceptable) { return; } + + optionInput.value = ''; + if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + if ( + setting.hasOwnProperty('value_names') && + Object.values(setting.value_names).includes(parseInt(option, 10)) + ) { + const optionName = Object.keys(setting.value_names).find( + (key) => setting.value_names[key] === parseInt(option, 10) + ); + tdLeft.innerText += ` [${optionName}]`; + } + 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; + range.dispatchEvent(new Event('change')); + rangeTbody.removeChild(tr); + }); + tdDelete.appendChild(deleteButton); + tr.appendChild(tdDelete); + + rangeTbody.appendChild(tr); + + // Save new option to settings + range.dispatchEvent(new Event('change')); + }); + + 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', 'random-low', 'random-middle', 'random-high'].includes(option)) { return; } + + const tr = document.createElement('tr'); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = option; + if ( + setting.hasOwnProperty('value_names') && + Object.values(setting.value_names).includes(parseInt(option, 10)) + ) { + const optionName = Object.keys(setting.value_names).find( + (key) => setting.value_names[key] === parseInt(option, 10) + ); + tdLeft.innerText += ` [${optionName}]`; + } + 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); + }); + + ['random', 'random-low', 'random-middle', '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-middle': + tdLeft.innerText = 'Random (Middle)'; + 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', `${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); + + 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); + }); + + rangeTable.appendChild(rangeTbody); + settingWrapper.appendChild(rangeTable); + break; + + case 'items-list': + const itemsList = this.#buildItemsDiv(settingName); + settingWrapper.appendChild(itemsList); + break; + + case 'locations-list': + const locationsList = this.#buildLocationsDiv(settingName); + settingWrapper.appendChild(locationsList); + break; + + case 'custom-list': + const customList = this.#buildListDiv(settingName, this.data.gameSettings[settingName].options); + settingWrapper.appendChild(customList); + break; + + default: + console.error(`Unknown setting type for ${this.name} setting ${settingName}: ${setting.type}`); + return; + } + + settingsWrapper.appendChild(settingWrapper); + }); + + return settingsWrapper; + } + + #buildItemPoolDiv() { + 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); + } + }); + + 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; + } + + #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; + } + + #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; + } + + #itemDropHandler(evt) { + evt.preventDefault(); + const sourceId = evt.dataTransfer.getData('text/plain'); + const sourceDiv = document.getElementById(sourceId); + + 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 itemDiv = newSetting === 'start_inventory' ? this.#buildItemQtyDiv(item) : this.#buildItemDiv(item); + + 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(`${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); + } + + // Remove the source drag object + sourceDiv.parentElement.removeChild(sourceDiv); + + // Save the updated settings + this.save(); + } + + #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 = this.#buildItemsDiv('start_hints'); + 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 = this.#buildLocationsDiv('start_location_hints'); + locationHintsWrapper.appendChild(locationHintsDiv); + itemHintsContainer.appendChild(locationHintsWrapper); + + hintsDiv.appendChild(itemHintsContainer); + return hintsDiv; + } + + #buildPriorityExclusionDiv() { + 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 = this.#buildLocationsDiv('priority_locations'); + priorityLocationsWrapper.appendChild(priorityLocationsDiv); + locationsContainer.appendChild(priorityLocationsWrapper); + + // Exclude Locations + const excludeLocationsWrapper = document.createElement('div'); + excludeLocationsWrapper.classList.add('locations-wrapper'); + excludeLocationsWrapper.innerText = 'Exclude Locations'; + + const excludeLocationsDiv = this.#buildLocationsDiv('exclude_locations'); + excludeLocationsWrapper.appendChild(excludeLocationsDiv); + locationsContainer.appendChild(excludeLocationsWrapper); + + locationsDiv.appendChild(locationsContainer); + return locationsDiv; + } + + // Builds a div for a setting whose value is a list of locations. + #buildLocationsDiv(setting) { + return this.#buildListDiv(setting, this.data.gameLocations, { + groups: this.data.gameLocationGroups, + descriptions: this.data.gameLocationDescriptions, + }); + } + + // Builds a div for a setting whose value is a list of items. + #buildItemsDiv(setting) { + return this.#buildListDiv(setting, this.data.gameItems, { + groups: this.data.gameItemGroups, + descriptions: this.data.gameItemDescriptions + }); + } + + // Builds a div for a setting named `setting` with a list value that can + // contain `items`. + // + // The `groups` option can be a list of additional options for this list + // (usually `item_name_groups` or `location_name_groups`) that are displayed + // in a special section at the top of the list. + // + // The `descriptions` option can be a map from item names or group names to + // descriptions for the user's benefit. + #buildListDiv(setting, items, {groups = [], descriptions = {}} = {}) { + const div = document.createElement('div'); + div.classList.add('simple-list'); + + groups.forEach((group) => { + const row = this.#addListRow(setting, group, descriptions[group]); + div.appendChild(row); + }); + + if (groups.length > 0) { + div.appendChild(document.createElement('hr')); + } + + items.forEach((item) => { + const row = this.#addListRow(setting, item, descriptions[item]); + div.appendChild(row); + }); + + return div; + } + + // Builds and returns a row for a list of checkboxes. + // + // If `help` is passed, it's displayed as a help tooltip for this list item. + #addListRow(setting, item, help = undefined) { + const row = document.createElement('div'); + row.classList.add('list-row'); + + const label = document.createElement('label'); + label.setAttribute('for', `${this.name}-${setting}-${item}`); + + const checkbox = document.createElement('input'); + checkbox.setAttribute('type', 'checkbox'); + checkbox.setAttribute('id', `${this.name}-${setting}-${item}`); + checkbox.setAttribute('data-game', this.name); + checkbox.setAttribute('data-setting', setting); + checkbox.setAttribute('data-option', item); + if (this.current[setting].includes(item)) { + checkbox.setAttribute('checked', '1'); + } + checkbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); + label.appendChild(checkbox); + + const name = document.createElement('span'); + name.innerText = item; + + if (help) { + const helpSpan = document.createElement('span'); + helpSpan.classList.add('interactive'); + helpSpan.setAttribute('data-tooltip', help); + helpSpan.innerText = '(?)'; + name.innerText += ' '; + name.appendChild(helpSpan); + + // Put the first 7 tooltips below their rows. CSS tooltips in scrolling + // containers can't be visible outside those containers, so this helps + // ensure they won't be pushed out the top. + if (helpSpan.parentNode.childNodes.length < 7) { + helpSpan.classList.add('tooltip-bottom'); + } + } + + label.appendChild(name); + + row.appendChild(label); + return row; + } + + #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 { + this.current[setting][option] = parseInt(evt.target.value, 10); + } + this.save(); + } + + #updateListSetting(evt) { + 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 (this.current[setting].includes(option)) { return; } + + 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; } + + this.current[setting].splice(this.current[setting].indexOf(option), 1); + } + this.save(); + } + + #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); + } + this.save(); + } + + // Saves the current settings to local storage. + save() { + this.#allSettings.save(); + } +} + +/** Create an anchor and trigger a download of a text file. */ +const download = (filename, text) => { + const downloadLink = document.createElement('a'); + downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text)) + downloadLink.setAttribute('download', filename); + downloadLink.style.display = 'none'; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +}; diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js deleted file mode 100644 index fb7d3a349b..0000000000 --- a/WebHostLib/static/assets/weighted-settings.js +++ /dev/null @@ -1,1252 +0,0 @@ -window.addEventListener('load', () => { - fetchSettingData().then((results) => { - 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)); - localStorage.setItem('weighted-settings-hash', settingHash); - localStorage.removeItem('weighted-settings'); - } - - if (settingHash !== md5(JSON.stringify(results))) { - 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."; - userMessage.classList.add('visible'); - userMessage.addEventListener('click', resetSettings); - } - - // Page setup - createDefaultSettings(results); - buildUI(results); - 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()); - - // 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; - }); -}); - -const resetSettings = () => { - localStorage.removeItem('weighted-settings'); - localStorage.removeItem('weighted-settings-hash') - window.location.reload(); -}; - -const fetchSettingData = () => new Promise((resolve, reject) => { - fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => { - try{ response.json().then((jsonObj) => resolve(jsonObj)); } - catch(error){ reject(error); } - }); -}); - -const createDefaultSettings = (settingData) => { - if (!localStorage.getItem('weighted-settings')) { - const newSettings = {}; - - // Transfer base options directly - for (let baseOption of Object.keys(settingData.baseOptions)){ - newSettings[baseOption] = settingData.baseOptions[baseOption]; - } - - // Set options per game - for (let game of Object.keys(settingData.games)) { - // Initialize game object - newSettings[game] = {}; - - // Transfer game settings - for (let gameSetting of Object.keys(settingData.games[game].gameSettings)){ - newSettings[game][gameSetting] = {}; - - const setting = settingData.games[game].gameSettings[gameSetting]; - switch(setting.type){ - case 'select': - setting.options.forEach((option) => { - newSettings[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; - if (setting.hasOwnProperty('defaultValue')) { - newSettings[game][gameSetting][setting.defaultValue] = 25; - } else { - newSettings[game][gameSetting][setting.min] = 25; - } - break; - - case 'items-list': - case 'locations-list': - case 'custom-list': - newSettings[game][gameSetting] = setting.defaultValue; - break; - - default: - console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`); - } - } - - 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 = []; - } - - localStorage.setItem('weighted-settings', JSON.stringify(newSettings)); - } -}; - -const buildUI = (settingData) => { - // Build the game-choice div - buildGameChoice(settingData.games); - - const gamesWrapper = document.getElementById('games-wrapper'); - Object.keys(settingData.games).forEach((game) => { - // Create game div, invisible by default - const gameDiv = document.createElement('div'); - gameDiv.setAttribute('id', `${game}-div`); - gameDiv.classList.add('game-div'); - gameDiv.classList.add('invisible'); - - const gameHeader = document.createElement('h2'); - gameHeader.innerText = game; - gameDiv.appendChild(gameHeader); - - const collapseButton = document.createElement('a'); - collapseButton.innerText = '(Collapse)'; - gameDiv.appendChild(collapseButton); - - const expandButton = document.createElement('a'); - expandButton.innerText = '(Expand)'; - 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))); - - const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings, - settingData.games[game].gameItems, settingData.games[game].gameLocations); - gameDiv.appendChild(weightedSettingsDiv); - - const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems); - gameDiv.appendChild(itemPoolDiv); - - const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations); - gameDiv.appendChild(hintsDiv); - - const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations); - 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'); - }); - - expandButton.addEventListener('click', () => { - collapseButton.classList.remove('invisible'); - 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); - }); - - table.appendChild(tbody); - gameChoiceDiv.appendChild(table); -}; - -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); - }); - - optionTable.appendChild(tbody); - settingWrapper.appendChild(optionTable); - break; - - case 'range': - case 'special_range': - const rangeTable = document.createElement('table'); - const rangeTbody = document.createElement('tbody'); - - 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 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-setting', settingName); - range.setAttribute('data-option', i); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateRangeSetting); - range.value = currentSettings[game][settingName][i] || 0; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${i}`) - 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')); } - }); - - 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; } - - 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', `${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); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${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; - range.dispatchEvent(new Event('change')); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); - - // Save new option to settings - range.dispatchEvent(new Event('change')); - }); - - 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; } - - 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', `${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); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${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); - }); - } - - ['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; - - 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'); - } - - const itemName = document.createElement('span'); - itemName.innerText = item.toString(); - - itemLabel.appendChild(itemCheckbox); - itemLabel.appendChild(itemName); - - itemRow.appendChild(itemLabel); - itemsList.appendChild((itemRow)); - }); - - settingWrapper.appendChild(itemsList); - break; - - 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 locationName = document.createElement('span'); - locationName.innerText = location.toString(); - - locationLabel.appendChild(locationCheckbox); - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - locationsList.appendChild((locationRow)); - }); - - settingWrapper.appendChild(locationsList); - break; - - 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'); - } - - const customItemName = document.createElement('span'); - customItemName.innerText = listItem.toString(); - - customItemLabel.appendChild(customItemCheckbox); - customItemLabel.appendChild(customItemName); - - customListRow.appendChild(customItemLabel); - customList.appendChild((customListRow)); - }); - - settingWrapper.appendChild(customList); - break; - - default: - console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`); - return; - } - - settingsWrapper.appendChild(settingWrapper); - }); - - return settingsWrapper; -}; - -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); - } - }); - - 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; -}; - -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; -}; - -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); - - itemQtyDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id')); - }); - return itemQtyDiv; -}; - -const itemDragoverHandler = (evt) => { - evt.preventDefault(); -}; - -const 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 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); - - 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 (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); - } - } - } 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); - - // Save the updated settings - localStorage.setItem('weighted-settings', JSON.stringify(currentSettings)); -}; - -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); - - const itemName = document.createElement('span'); - itemName.innerText = item; - itemLabel.appendChild(itemName); - - 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); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - locationHintsDiv.appendChild(locationRow); - }); - - locationHintsWrapper.appendChild(locationHintsDiv); - itemHintsContainer.appendChild(locationHintsWrapper); - - 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); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - 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); - - const locationName = document.createElement('span'); - locationName.innerText = location; - locationLabel.appendChild(locationName); - - locationRow.appendChild(locationLabel); - excludeLocationsDiv.appendChild(locationRow); - }); - - excludeLocationsWrapper.appendChild(excludeLocationsDiv); - locationsContainer.appendChild(excludeLocationsWrapper); - - 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', - }); - }); - } else { - gameDiv.classList.add('invisible'); - gameOption.classList.remove('jump-link'); - - } - }); -}; - -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); - } - 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; } - - options[game][setting].splice(options[game][setting].indexOf(option), 1); - } - localStorage.setItem('weighted-settings', JSON.stringify(options)); -}; - -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)); -}; - -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; - } - - // 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; -}; - -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) => { - const downloadLink = document.createElement('a'); - downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text)) - downloadLink.setAttribute('download', filename); - downloadLink.style.display = 'none'; - document.body.appendChild(downloadLink); - 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/styles/landing.css b/WebHostLib/static/styles/landing.css index 202c43badd..96975553c1 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/player-settings.css b/WebHostLib/static/styles/player-options.css similarity index 59% rename from WebHostLib/static/styles/player-settings.css rename to WebHostLib/static/styles/player-options.css index e6e0c29292..cc2d5e2de5 100644 --- a/WebHostLib/static/styles/player-settings.css +++ b/WebHostLib/static/styles/player-options.css @@ -4,7 +4,7 @@ html{ background-size: 650px 650px; } -#player-settings{ +#player-options{ box-sizing: border-box; max-width: 1024px; margin-left: auto; @@ -15,14 +15,14 @@ html{ color: #eeffeb; } -#player-settings #player-settings-button-row{ +#player-options #player-options-button-row{ display: flex; flex-direction: row; justify-content: space-between; margin-top: 15px; } -#player-settings code{ +#player-options code{ background-color: #d9cd8e; border-radius: 4px; padding-left: 0.25rem; @@ -30,7 +30,7 @@ html{ color: #000000; } -#player-settings #user-message{ +#player-options #user-message{ display: none; width: calc(100% - 8px); background-color: #ffe86b; @@ -40,12 +40,12 @@ html{ text-align: center; } -#player-settings #user-message.visible{ +#player-options #user-message.visible{ display: block; cursor: pointer; } -#player-settings h1{ +#player-options h1{ font-size: 2.5rem; font-weight: normal; width: 100%; @@ -53,7 +53,7 @@ html{ text-shadow: 1px 1px 4px #000000; } -#player-settings h2{ +#player-options h2{ font-size: 40px; font-weight: normal; width: 100%; @@ -62,22 +62,22 @@ html{ text-shadow: 1px 1px 2px #000000; } -#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{ +#player-options h3, #player-options h4, #player-options h5, #player-options h6{ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); } -#player-settings input:not([type]){ +#player-options input:not([type]){ border: 1px solid #000000; padding: 3px; border-radius: 3px; min-width: 150px; } -#player-settings input:not([type]):focus{ +#player-options input:not([type]):focus{ border: 1px solid #ffffff; } -#player-settings select{ +#player-options select{ border: 1px solid #000000; padding: 3px; border-radius: 3px; @@ -85,72 +85,97 @@ html{ background-color: #ffffff; } -#player-settings #game-options, #player-settings #rom-options{ +#player-options #game-options, #player-options #rom-options{ display: flex; flex-direction: row; } -#player-settings .left, #player-settings .right{ +#player-options #meta-options { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 3px; +} + +#player-options div { + display: flex; flex-grow: 1; } -#player-settings .left{ +#player-options #meta-options label { + display: inline-block; + min-width: 180px; + flex-grow: 1; +} + +#player-options #meta-options input, +#player-options #meta-options select { + box-sizing: border-box; + min-width: 150px; + width: 50%; +} + +#player-options .left, #player-options .right{ + flex-grow: 1; +} + +#player-options .left{ margin-right: 10px; } -#player-settings .right{ +#player-options .right{ margin-left: 10px; } -#player-settings table{ +#player-options table{ margin-bottom: 30px; width: 100%; } -#player-settings table .select-container{ +#player-options table .select-container{ display: flex; flex-direction: row; } -#player-settings table .select-container select{ +#player-options table .select-container select{ min-width: 200px; flex-grow: 1; } -#player-settings table select:disabled{ +#player-options table select:disabled{ background-color: lightgray; } -#player-settings table .range-container{ +#player-options table .range-container{ display: flex; flex-direction: row; } -#player-settings table .range-container input[type=range]{ +#player-options table .range-container input[type=range]{ flex-grow: 1; } -#player-settings table .range-value{ +#player-options table .range-value{ min-width: 20px; margin-left: 0.25rem; } -#player-settings table .special-range-container{ +#player-options table .named-range-container{ display: flex; flex-direction: column; } -#player-settings table .special-range-wrapper{ +#player-options table .named-range-wrapper{ display: flex; flex-direction: row; margin-top: 0.25rem; } -#player-settings table .special-range-wrapper input[type=range]{ +#player-options table .named-range-wrapper input[type=range]{ flex-grow: 1; } -#player-settings table .randomize-button { +#player-options table .randomize-button { max-height: 24px; line-height: 16px; padding: 2px 8px; @@ -160,23 +185,23 @@ html{ border-radius: 3px; } -#player-settings table .randomize-button.active { +#player-options table .randomize-button.active { background-color: #ffef00; /* Same as .interactive in globalStyles.css */ } -#player-settings table .randomize-button[data-tooltip]::after { +#player-options table .randomize-button[data-tooltip]::after { left: unset; right: 0; } -#player-settings table label{ +#player-options table label{ display: block; min-width: 200px; margin-right: 4px; cursor: default; } -#player-settings th, #player-settings td{ +#player-options th, #player-options td{ border: none; padding: 3px; font-size: 17px; @@ -184,17 +209,23 @@ html{ } @media all and (max-width: 1024px) { - #player-settings { + #player-options { border-radius: 0; } - #player-settings #game-options{ + #player-options #meta-options { + flex-direction: column; + justify-content: flex-start; + gap: 6px; + } + + #player-options #game-options{ justify-content: flex-start; flex-wrap: wrap; } - #player-settings .left, - #player-settings .right { + #player-options .left, + #player-options .right { margin: 0; } diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css index 1e9a98c17a..7396daa954 100644 --- a/WebHostLib/static/styles/supportedGames.css +++ b/WebHostLib/static/styles/supportedGames.css @@ -18,10 +18,16 @@ 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; - cursor: pointer; + padding-right: 8px; } #games p.collapsed{ @@ -42,12 +48,12 @@ margin-bottom: 7px; } -#games #page-controls{ +#games .page-controls{ display: flex; flex-direction: row; margin-top: 0.25rem; } -#games #page-controls button{ +#games .page-controls button{ margin-left: 0.5rem; } diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css index 0cc2ede59f..8fcb0c9230 100644 --- a/WebHostLib/static/styles/tracker.css +++ b/WebHostLib/static/styles/tracker.css @@ -7,138 +7,55 @@ width: calc(100% - 1rem); } -#tracker-wrapper a{ +#tracker-wrapper a { color: #234ae4; text-decoration: none; cursor: pointer; } -.table-wrapper{ - overflow-y: auto; - overflow-x: auto; - margin-bottom: 1rem; -} - -#tracker-header-bar{ +#tracker-header-bar { display: flex; flex-direction: row; justify-content: flex-start; + align-content: center; line-height: 20px; + gap: 0.5rem; + margin-bottom: 1rem; } -#tracker-header-bar .info{ +#tracker-header-bar .info { color: #ffffff; -} - -#search{ - border: 1px solid #000000; - border-radius: 3px; - padding: 3px; - width: 200px; - margin-bottom: 0.5rem; - margin-right: 1rem; -} - -#multi-stream-link{ - margin-right: 1rem; -} - -div.dataTables_wrapper.no-footer .dataTables_scrollBody{ - border: none; -} - -table.dataTable{ - color: #000000; -} - -table.dataTable thead{ - font-family: LexendDeca-Regular, sans-serif; -} - -table.dataTable tbody, table.dataTable tfoot{ - background-color: #dce2bd; - font-family: LexendDeca-Light, sans-serif; -} - -table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{ - background-color: #e2eabb; -} - -table.dataTable tbody td, table.dataTable tfoot td{ - padding: 4px 6px; -} - -table.dataTable, table.dataTable.no-footer{ - border-left: 1px solid #bba967; - width: calc(100% - 2px) !important; - font-size: 1rem; -} - -table.dataTable thead th{ - position: -webkit-sticky; - position: sticky; - background-color: #b0a77d; - top: 0; -} - -table.dataTable thead th.upper-row{ - position: -webkit-sticky; - position: sticky; - background-color: #b0a77d; - height: 36px; - top: 0; -} - -table.dataTable thead th.lower-row{ - position: -webkit-sticky; - position: sticky; - background-color: #b0a77d; - height: 22px; - top: 46px; -} - -table.dataTable tbody td, table.dataTable tfoot td{ - border: 1px solid #bba967; -} - -table.dataTable tfoot td{ - font-weight: bold; -} - -div.dataTables_scrollBody{ - background-color: inherit !important; -} - -table.dataTable .center-column{ - text-align: center; -} - -img.alttp-sprite { - height: auto; - max-height: 32px; - min-height: 14px; -} - -.item-acquired{ - background-color: #d3c97d; + padding: 2px; + flex-grow: 1; + align-self: center; + text-align: justify; } #tracker-navigation { - display: inline-flex; + display: flex; + flex-wrap: wrap; + margin: 0 0.5rem 0.5rem 0.5rem; + user-select: none; + height: 2rem; +} + +.tracker-navigation-bar { + display: flex; background-color: #b0a77d; - margin: 0.5rem; border-radius: 4px; } .tracker-navigation-button { - display: block; + display: flex; + justify-content: center; + align-items: center; margin: 4px; padding-left: 12px; padding-right: 12px; border-radius: 4px; text-align: center; font-size: 14px; - color: #000; + color: black !important; font-weight: lighter; } @@ -150,6 +67,100 @@ img.alttp-sprite { background-color: rgb(220, 226, 189); } +.table-wrapper { + overflow-y: auto; + overflow-x: auto; + margin-bottom: 1rem; + resize: vertical; +} + +#search { + border: 1px solid #000000; + border-radius: 3px; + padding: 3px; + width: 200px; +} + +div.dataTables_wrapper.no-footer .dataTables_scrollBody { + border: none; +} + +table.dataTable { + color: #000000; +} + +table.dataTable thead { + font-family: LexendDeca-Regular, sans-serif; +} + +table.dataTable tbody, table.dataTable tfoot { + background-color: #dce2bd; + font-family: LexendDeca-Light, sans-serif; +} + +table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover { + background-color: #e2eabb; +} + +table.dataTable tbody td, table.dataTable tfoot td { + padding: 4px 6px; +} + +table.dataTable, table.dataTable.no-footer { + border-left: 1px solid #bba967; + width: calc(100% - 2px) !important; + font-size: 1rem; +} + +table.dataTable thead th { + position: -webkit-sticky; + position: sticky; + background-color: #b0a77d; + top: 0; +} + +table.dataTable thead th.upper-row { + position: -webkit-sticky; + position: sticky; + background-color: #b0a77d; + height: 36px; + top: 0; +} + +table.dataTable thead th.lower-row { + position: -webkit-sticky; + position: sticky; + background-color: #b0a77d; + height: 22px; + top: 46px; +} + +table.dataTable tbody td, table.dataTable tfoot td { + border: 1px solid #bba967; +} + +table.dataTable tfoot td { + font-weight: bold; +} + +div.dataTables_scrollBody { + background-color: inherit !important; +} + +table.dataTable .center-column { + text-align: center; +} + +img.icon-sprite { + height: auto; + max-height: 32px; + min-height: 14px; +} + +.item-acquired { + background-color: #d3c97d; +} + @media all and (max-width: 1700px) { table.dataTable thead th.upper-row{ position: -webkit-sticky; @@ -159,7 +170,7 @@ img.alttp-sprite { top: 0; } - table.dataTable thead th.lower-row{ + table.dataTable thead th.lower-row { position: -webkit-sticky; position: sticky; background-color: #b0a77d; @@ -167,11 +178,11 @@ img.alttp-sprite { top: 37px; } - table.dataTable, table.dataTable.no-footer{ + table.dataTable, table.dataTable.no-footer { font-size: 0.8rem; } - img.alttp-sprite { + img.icon-sprite { height: auto; max-height: 24px; min-height: 10px; @@ -187,7 +198,7 @@ img.alttp-sprite { top: 0; } - table.dataTable thead th.lower-row{ + table.dataTable thead th.lower-row { position: -webkit-sticky; position: sticky; background-color: #b0a77d; @@ -195,11 +206,11 @@ img.alttp-sprite { top: 32px; } - table.dataTable, table.dataTable.no-footer{ + table.dataTable, table.dataTable.no-footer { font-size: 0.6rem; } - img.alttp-sprite { + img.icon-sprite { height: auto; max-height: 20px; min-height: 10px; diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-options.css similarity index 97% rename from WebHostLib/static/styles/weighted-settings.css rename to WebHostLib/static/styles/weighted-options.css index cc5231634e..8a66ca2370 100644 --- a/WebHostLib/static/styles/weighted-settings.css +++ b/WebHostLib/static/styles/weighted-options.css @@ -292,6 +292,12 @@ html{ margin-right: 0.5rem; } +#weighted-settings .simple-list hr{ + width: calc(100% - 2px); + margin: 2px auto; + border-bottom: 1px solid rgb(255 255 255 / 0.6); +} + #weighted-settings .invisible{ display: none; } diff --git a/WebHostLib/templates/checkResult.html b/WebHostLib/templates/checkResult.html index c245d7381a..75ae7479f5 100644 --- a/WebHostLib/templates/checkResult.html +++ b/WebHostLib/templates/checkResult.html @@ -28,6 +28,10 @@ {% endfor %} + {% if combined_yaml %} +

Combined File Download

+

Download

+ {% endif %} {% endblock %} diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html index 1c2fcd44c0..5a53320408 100644 --- a/WebHostLib/templates/genericTracker.html +++ b/WebHostLib/templates/genericTracker.html @@ -1,36 +1,57 @@ -{% extends 'tablepage.html' %} +{% extends "tablepage.html" %} {% block head %} {{ super() }} {{ player_name }}'s Tracker - - - + + + {% endblock %} {% block body %} - {% include 'header/dirtHeader.html' %} -
-
- - This tracker will automatically update itself periodically. + {% include "header/dirtHeader.html" %} + +
+
+ + 🡸 Return to Multiworld Tracker + + {% if game_specific_tracker %} + + Game-Specific Tracker + + {% endif %}
+
+ +
+
+ +
This tracker will automatically update itself periodically.
+
+
- + - {% for id, count in inventory.items() %} - - - - - + {% for id, count in inventory.items() if count > 0 %} + + + + + {%- endfor -%} @@ -39,24 +60,62 @@
Item AmountOrder ReceivedLast Order Received
{{ id | item_name }}{{ count }}{{received_items[id]}}
{{ item_id_to_name[game][id] }}{{ count }}{{ received_items[id] }}
- - - - + + + + - {% for name in checked_locations %} + + {%- for location in locations -%} - - + + - {%- endfor -%} - {% for name in not_checked_locations %} + {%- endfor -%} + + +
LocationChecked
LocationChecked
{{ name | location_name}}{{ location_id_to_name[game][location] }} + {% if location in checked_locations %}✔{% endif %} +
+
+
+ + - - + + + + + + + - {%- endfor -%} + + + {%- for hint in hints -%} + + + + + + + + + + {%- endfor -%}
{{ name | location_name}}FinderReceiverItemLocationGameEntranceFound
+ {% if hint.finding_player == player %} + {{ player_names_with_alias[(team, hint.finding_player)] }} + {% else %} + {{ player_names_with_alias[(team, hint.finding_player)] }} + {% endif %} + + {% if hint.receiving_player == player %} + {{ player_names_with_alias[(team, hint.receiving_player)] }} + {% else %} + {{ player_names_with_alias[(team, hint.receiving_player)] }} + {% endif %} + {{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}{{ games[(team, hint.finding_player)] }}{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}{% if hint.found %}✔{% endif %}
diff --git a/WebHostLib/templates/hintTable.html b/WebHostLib/templates/hintTable.html deleted file mode 100644 index 00b74111ea..0000000000 --- a/WebHostLib/templates/hintTable.html +++ /dev/null @@ -1,28 +0,0 @@ -{% for team, hints in hints.items() %} -
- - - - - - - - - - - - - {%- for hint in hints -%} - - - - - - - - - {%- endfor -%} - -
FinderReceiverItemLocationEntranceFound
{{ long_player_names[team, hint.finding_player] }}{{ long_player_names[team, hint.receiving_player] }}{{ hint.item|item_name }}{{ hint.location|location_name }}{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}{% if hint.found %}✔{% endif %}
-
-{% endfor %} \ No newline at end of file diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index ba15d64aca..2981c41452 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -3,6 +3,16 @@ {% block head %} Multiworld {{ room.id|suuid }} {% if should_refresh %}{% endif %} + + + + {% if room.seed.slots|length < 2 %} + + {% else %} + + {% endif %} {% endblock %} diff --git a/WebHostLib/templates/landing.html b/WebHostLib/templates/landing.html index fd45b78cfb..b489ef18ac 100644 --- a/WebHostLib/templates/landing.html +++ b/WebHostLib/templates/landing.html @@ -49,9 +49,9 @@ 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/lttpMultiTracker.html b/WebHostLib/templates/lttpMultiTracker.html deleted file mode 100644 index 2b943a22b0..0000000000 --- a/WebHostLib/templates/lttpMultiTracker.html +++ /dev/null @@ -1,171 +0,0 @@ -{% extends 'tablepage.html' %} -{% block head %} - {{ super() }} - ALttP Multiworld Tracker - - - - -{% endblock %} - -{% block body %} - {% include 'header/dirtHeader.html' %} - {% include 'multiTrackerNavigation.html' %} -
-
- - - - Multistream - - - Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically. -
-
- {% for team, players in inventory.items() %} -
- - - - - - {%- for name in tracking_names -%} - {%- if name in icons -%} - - {%- else -%} - - {%- endif -%} - {%- endfor -%} - - - - {%- for player, items in players.items() -%} - - - {%- if (team, loop.index) in video -%} - {%- if video[(team, loop.index)][0] == "Twitch" -%} - - {%- elif video[(team, loop.index)][0] == "Youtube" -%} - - {%- endif -%} - {%- else -%} - - {%- endif -%} - {%- for id in tracking_ids -%} - {%- if items[id] -%} - - {%- else -%} - - {%- endif -%} - {% endfor %} - - {%- endfor -%} - -
#Name - {{ name|e }} - {{ name|e }}
{{ loop.index }} - - {{ player_names[(team, loop.index)] }} - ▶️ - - {{ player_names[(team, loop.index)] }} - ▶️{{ player_names[(team, loop.index)] }} - {% if id in multi_items %}{{ items[id] }}{% else %}✔️{% endif %}
-
- {% endfor %} - - {% for team, players in checks_done.items() %} -
- - - - - - {% for area in ordered_areas %} - {% set colspan = 1 %} - {% if area in key_locations %} - {% set colspan = colspan + 1 %} - {% endif %} - {% if area in big_key_locations %} - {% set colspan = colspan + 1 %} - {% endif %} - {% if area in icons %} - - {%- else -%} - - {%- endif -%} - {%- endfor -%} - - - - - {% for area in ordered_areas %} - - {% if area in key_locations %} - - {% endif %} - {% if area in big_key_locations %} - - {%- endif -%} - {%- endfor -%} - - - - {%- for player, checks in players.items() -%} - - - - {%- for area in ordered_areas -%} - {% if player in checks_in_area and area in checks_in_area[player] %} - {%- set checks_done = checks[area] -%} - {%- set checks_total = checks_in_area[player][area] -%} - {%- if checks_done == checks_total -%} - - {%- else -%} - - {%- endif -%} - {%- if area in key_locations -%} - - {%- endif -%} - {%- if area in big_key_locations -%} - - {%- endif -%} - {% else %} - - {%- if area in key_locations -%} - - {%- endif -%} - {%- if area in big_key_locations -%} - - {%- endif -%} - {% endif %} - {%- endfor -%} - - {%- if activity_timers[(team, player)] -%} - - {%- else -%} - - {%- endif -%} - - {%- endfor -%} - -
#Name - {{ area }}{{ area }}%Last
Activity
- Checks - - Small Key - - Big Key -
{{ loop.index }}{{ player_names[(team, loop.index)]|e }} - {{ checks_done }}/{{ checks_total }}{{ checks_done }}/{{ checks_total }}{{ inventory[team][player][small_key_ids[area]] }}{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}{{ percent_total_checks_done[team][player] }}{{ activity_timers[(team, player)].total_seconds() }}None
-
- {% endfor %} - {% include "hintTable.html" with context %} -
-
-{% endblock %} diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index 746399da74..0722ee3174 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -50,6 +50,9 @@ {% elif patch.game == "Dark Souls III" %} Download JSON File... + {% elif patch.game == "Final Fantasy Mystic Quest" %} + + Download APMQ File... {% else %} No file to download for this game. {% endif %} diff --git a/WebHostLib/templates/multiFactorioTracker.html b/WebHostLib/templates/multiFactorioTracker.html deleted file mode 100644 index faca756ee9..0000000000 --- a/WebHostLib/templates/multiFactorioTracker.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "multiTracker.html" %} -{% block custom_table_headers %} - - Logistic Science Pack - - - Military Science Pack - - - Chemical Science Pack - - - Production Science Pack - - - Utility Science Pack - - - Space Science Pack - -{% endblock %} -{% block custom_table_row scoped %} -{% if games[player] == "Factorio" %} -{% set player_inventory = named_inventory[team][player] %} -{% set prog_science = player_inventory["progressive-science-pack"] %} -{% if player_inventory["logistic-science-pack"] or prog_science %}✔{% endif %} -{% if player_inventory["military-science-pack"] or prog_science > 1%}✔{% endif %} -{% if player_inventory["chemical-science-pack"] or prog_science > 2%}✔{% endif %} -{% if player_inventory["production-science-pack"] or prog_science > 3%}✔{% endif %} -{% if player_inventory["utility-science-pack"] or prog_science > 4%}✔{% endif %} -{% if player_inventory["space-science-pack"] or prog_science > 5%}✔{% endif %} -{% else %} -❌ -❌ -❌ -❌ -❌ -❌ -{% endif %} -{% endblock%} diff --git a/WebHostLib/templates/multiTracker.html b/WebHostLib/templates/multiTracker.html deleted file mode 100644 index 40d89eb4c6..0000000000 --- a/WebHostLib/templates/multiTracker.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends 'tablepage.html' %} -{% block head %} - {{ super() }} - Multiworld Tracker - - -{% endblock %} - -{% block body %} - {% include 'header/dirtHeader.html' %} - {% include 'multiTrackerNavigation.html' %} -
-
- - - - Multistream - - - Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically. -
-
- {% for team, players in checks_done.items() %} -
- - - - - - - - {% block custom_table_headers %} - {# implement this block in game-specific multi trackers #} - {% endblock %} - - - - - - - {%- for player, checks in players.items() -%} - - - - - - {% block custom_table_row scoped %} - {# implement this block in game-specific multi trackers #} - {% endblock %} - - - {%- if activity_timers[team, player] -%} - - {%- else -%} - - {%- endif -%} - - {%- endfor -%} - - {% if not self.custom_table_headers() | trim %} - - - - - - - - - - - - {% endif %} -
#NameGameStatusChecks%Last
Activity
{{ loop.index }}{{ player_names[(team, loop.index)]|e }}{{ games[player] }}{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing", - 30: "Goal Completed"}.get(states[team, player], "Unknown State") }} - {{ checks["Total"] }}/{{ locations[player] | length }} - {{ percent_total_checks_done[team][player] }}{{ activity_timers[team, player].total_seconds() }}None
TotalAll Games{{ completed_worlds }}/{{ players|length }} Complete{{ players.values()|sum(attribute='Total') }}/{{ total_locations[team] }}{{ (players.values()|sum(attribute='Total') / total_locations[team] * 100) | int }}
-
- {% endfor %} - {% include "hintTable.html" with context %} -
-
-{% endblock %} diff --git a/WebHostLib/templates/multiTrackerNavigation.html b/WebHostLib/templates/multiTrackerNavigation.html deleted file mode 100644 index 7fc405b6fb..0000000000 --- a/WebHostLib/templates/multiTrackerNavigation.html +++ /dev/null @@ -1,9 +0,0 @@ -{%- if enabled_multiworld_trackers|length > 1 -%} -
- {% for enabled_tracker in enabled_multiworld_trackers %} - {% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %} - {{ enabled_tracker.name }} - {% endfor %} -
-{%- endif -%} diff --git a/WebHostLib/templates/multitracker.html b/WebHostLib/templates/multitracker.html new file mode 100644 index 0000000000..b16d4714ec --- /dev/null +++ b/WebHostLib/templates/multitracker.html @@ -0,0 +1,144 @@ +{% extends "tablepage.html" %} +{% block head %} + {{ super() }} + Multiworld Tracker + + +{% endblock %} + +{% block body %} + {% include "header/dirtHeader.html" %} + {% include "multitrackerNavigation.html" %} + +
+
+ + + + +
+ Clicking on a slot's number will bring up the slot-specific tracker. + This tracker will automatically update itself periodically. +
+
+ +
+ {%- for team, players in room_players.items() -%} +
+ + + + + + {% if current_tracker == "Generic" %}{% endif %} + + {% block custom_table_headers %} + {# Implement this block in game-specific multi-trackers. #} + {% endblock %} + + + + + + + {%- for player in players -%} + {%- if current_tracker == "Generic" or games[(team, player)] == current_tracker -%} + + + + {%- if current_tracker == "Generic" -%} + + {%- endif -%} + + + {% block custom_table_row scoped %} + {# Implement this block in game-specific multi-trackers. #} + {% endblock %} + + {% set location_count = locations[(team, player)] | length %} + + + + + {%- if activity_timers[(team, player)] -%} + + {%- else -%} + + {%- endif -%} + + {%- endif -%} + {%- endfor -%} + + + {%- if not self.custom_table_headers() | trim -%} + + + + + + + + + + + {%- endif -%} +
#NameGameStatusChecks%Last
Activity
+ + {{ player }} + + {{ player_names_with_alias[(team, player)] | e }}{{ games[(team, player)] }} + {{ + { + 0: "Disconnected", + 5: "Connected", + 10: "Ready", + 20: "Playing", + 30: "Goal Completed" + }.get(states[(team, player)], "Unknown State") + }} + + {{ locations_complete[(team, player)] }}/{{ location_count }} + + {%- if locations[(team, player)] | length > 0 -%} + {% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %} + {{ "{0:.2f}".format(percentage_of_completion) }} + {%- else -%} + 100.00 + {%- endif -%} + {{ activity_timers[(team, player)].total_seconds() }}None
TotalAll Games{{ completed_worlds[team] }}/{{ players | length }} Complete + {{ total_team_locations_complete[team] }}/{{ total_team_locations[team] }} + + {%- if total_team_locations[team] == 0 -%} + 100 + {%- else -%} + {{ "{0:.2f}".format(total_team_locations_complete[team] / total_team_locations[team] * 100) }} + {%- endif -%} +
+
+ + {%- endfor -%} + + {% block custom_tables %} + {# Implement this block to create custom tables in game-specific multi-trackers. #} + {% endblock %} + + {% include "multitrackerHintTable.html" with context %} +
+
+{% endblock %} diff --git a/WebHostLib/templates/multitrackerHintTable.html b/WebHostLib/templates/multitrackerHintTable.html new file mode 100644 index 0000000000..a931e9b048 --- /dev/null +++ b/WebHostLib/templates/multitrackerHintTable.html @@ -0,0 +1,37 @@ +{% for team, hints in hints.items() %} +
+ + + + + + + + + + + + + + {%- for hint in hints -%} + {%- + if current_tracker == "Generic" or ( + games[(team, hint.finding_player)] == current_tracker or + games[(team, hint.receiving_player)] == current_tracker + ) + -%} + + + + + + + + + + {% endif %} + {%- endfor -%} + +
FinderReceiverItemLocationGameEntranceFound
{{ player_names_with_alias[(team, hint.finding_player)] }}{{ player_names_with_alias[(team, hint.receiving_player)] }}{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}{{ games[(team, hint.finding_player)] }}{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}{% if hint.found %}✔{% endif %}
+
+{% endfor %} diff --git a/WebHostLib/templates/multitrackerNavigation.html b/WebHostLib/templates/multitrackerNavigation.html new file mode 100644 index 0000000000..1256181b27 --- /dev/null +++ b/WebHostLib/templates/multitrackerNavigation.html @@ -0,0 +1,16 @@ +{% if enabled_trackers | length > 1 %} +
+ {# Multitracker game navigation. #} +
+ {%- for game_tracker in enabled_trackers -%} + {%- set tracker_url = url_for("get_multiworld_tracker", tracker=room.tracker, game=game_tracker) -%} + + {{ game_tracker }} + + {%- endfor -%} +
+
+{% endif %} diff --git a/WebHostLib/templates/multitracker__ALinkToThePast.html b/WebHostLib/templates/multitracker__ALinkToThePast.html new file mode 100644 index 0000000000..8cea5ba057 --- /dev/null +++ b/WebHostLib/templates/multitracker__ALinkToThePast.html @@ -0,0 +1,205 @@ +{% extends "multitracker.html" %} +{% block head %} + {{ super() }} + + +{% endblock %} + +{# List all tracker-relevant icons. Format: (Name, Image URL) #} +{%- set icons = { + "Blue Shield": "https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png", + "Red Shield": "https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png", + "Mirror Shield": "https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png", + "Fighter Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920", + "Master Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920", + "Tempered Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920", + "Golden Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920", + "Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c", + "Silver Bow": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920", + "Green Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920", + "Blue Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920", + "Red Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920", + "Power Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920", + "Titan Mitts": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920", + "Progressive Sword": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725", + "Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9", + "Progressive Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920", + "Flippers": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920", + "Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e", + "Progressive Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed", + "Blue Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e", + "Red Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400", + "Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b", + "Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59", + "Magic Powder": "https://www.zeldadungeon.net/wiki/images/thumb/6/62/MagicPowder-ALttP-Sprite.png/86px-MagicPowder-ALttP-Sprite.png", + "Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0", + "Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc", + "Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26", + "Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5", + "Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879", + "Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce", + "Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500", + "Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05", + "Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390", + "Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6", + "Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744", + "Bottle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b", + "Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943", + "Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54", + "Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832", + "Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc", + "Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48", + "Triforce Piece": "https://www.zeldadungeon.net/wiki/images/thumb/5/54/Triforce_Fragment_-_BS_Zelda.png/62px-Triforce_Fragment_-_BS_Zelda.png", + "Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e", + "Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d", + "Chest": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda", + "Light World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6", + "Dark World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc", + "Hyrule Castle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d3/ALttP_Ball_and_Chain_Trooper_Sprite.png?version=1768a87c06d29cc8e7ddd80b9fa516be", + "Agahnims Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1e/ALttP_Agahnim_Sprite.png?version=365956e61b0c2191eae4eddbe591dab5", + "Desert Palace": "https://www.zeldadungeon.net/wiki/images/2/25/Lanmola-ALTTP-Sprite.png", + "Eastern Palace": "https://www.zeldadungeon.net/wiki/images/d/dc/RedArmosKnight.png", + "Tower of Hera": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/ALttP_Moldorm_Sprite.png?version=c588257bdc2543468e008a6b30f262a7", + "Palace of Darkness": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Helmasaur_King_Sprite.png?version=ab8a4a1cfd91d4fc43466c56cba30022", + "Swamp Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Arrghus_Sprite.png?version=b098be3122e53f751b74f4a5ef9184b5", + "Skull Woods": "https://alttp-wiki.net/images/6/6a/Mothula.png", + "Thieves Town": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/86/ALttP_Blind_the_Thief_Sprite.png?version=3833021bfcd112be54e7390679047222", + "Ice Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Kholdstare_Sprite.png?version=e5a1b0e8b2298e550d85f90bf97045c0", + "Misery Mire": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8", + "Turtle Rock": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be", + "Ganons Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74", +} -%} + +{%- block custom_table_headers %} +{#- macro that creates a table header with display name and image -#} +{%- macro make_header(name, img_src) %} + + {{ name }} + +{% endmacro -%} + +{#- call the macro to build the table header -#} +{%- for name in tracking_names %} + {%- if name in icons -%} + + {{ name | e }} + + {%- endif %} +{% endfor -%} +{% endblock %} + +{# build each row of custom entries #} +{% block custom_table_row scoped %} + {%- for id in tracking_ids -%} +{# {{ checks }}#} + {%- if inventories[(team, player)][id] -%} + + {% if id in multi_items %}{{ inventories[(team, player)][id] }}{% else %}✔️{% endif %} + + {%- else -%} + + {%- endif -%} + {% endfor %} +{% endblock %} + +{% block custom_tables %} + +{% for team, _ in total_team_locations.items() %} +
+ + + + + + {% for area in ordered_areas %} + {% set colspan = 1 %} + {% if area in key_locations %} + {% set colspan = colspan + 1 %} + {% endif %} + {% if area in big_key_locations %} + {% set colspan = colspan + 1 %} + {% endif %} + {% if area in icons %} + + {%- else -%} + + {%- endif -%} + {%- endfor -%} + + + + + {% for area in ordered_areas %} + + {% if area in key_locations %} + + {% endif %} + {% if area in big_key_locations %} + + {%- endif -%} + {%- endfor -%} + + + + {%- for (checks_team, player), area_checks in checks_done.items() if games[(team, player)] == current_tracker and team == checks_team -%} + + + + {%- for area in ordered_areas -%} + {% if (team, player) in checks_in_area and area in checks_in_area[(team, player)] %} + {%- set checks_done = area_checks[area] -%} + {%- set checks_total = checks_in_area[(team, player)][area] -%} + {%- if checks_done == checks_total -%} + + {%- else -%} + + {%- endif -%} + {%- if area in key_locations -%} + + {%- endif -%} + {%- if area in big_key_locations -%} + + {%- endif -%} + {% else %} + + {%- if area in key_locations -%} + + {%- endif -%} + {%- if area in big_key_locations -%} + + {%- endif -%} + {% endif %} + {%- endfor -%} + + + + {%- if activity_timers[(team, player)] -%} + + {%- else -%} + + {%- endif -%} + + {%- endfor -%} + +
#Name + {{ area }}{{ area }}%Last
Activity
+ Checks + + Small Key + + Big Key +
{{ player }}{{ player_names_with_alias[(team, player)] | e }} + {{ checks_done }}/{{ checks_total }}{{ checks_done }}/{{ checks_total }}{{ inventories[(team, player)][small_key_ids[area]] }}{% if inventories[(team, player)][big_key_ids[area]] %}✔️{% endif %} + {% set location_count = locations[(team, player)] | length %} + {%- if locations[(team, player)] | length > 0 -%} + {% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %} + {{ "{0:.2f}".format(percentage_of_completion) }} + {%- else -%} + 100.00 + {%- endif -%} + {{ activity_timers[(team, player)].total_seconds() }}None
+
+{% endfor %} + +{% endblock %} diff --git a/WebHostLib/templates/multitracker__Factorio.html b/WebHostLib/templates/multitracker__Factorio.html new file mode 100644 index 0000000000..a7ad824db4 --- /dev/null +++ b/WebHostLib/templates/multitracker__Factorio.html @@ -0,0 +1,41 @@ +{% extends "multitracker.html" %} +{# 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) %} + + {{ 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 %} + {%- set player_inventory = inventories[(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 %} + ✔️ + {% else %} + + {% endif %} + {% endfor -%} +{% endblock%} diff --git a/WebHostLib/templates/pageWrapper.html b/WebHostLib/templates/pageWrapper.html index ec7888ac73..c7dda523ef 100644 --- a/WebHostLib/templates/pageWrapper.html +++ b/WebHostLib/templates/pageWrapper.html @@ -16,7 +16,7 @@ {% with messages = get_flashed_messages() %} {% if messages %}
- {% for message in messages %} + {% for message in messages | unique %}
{{ message }}
{% endfor %}
diff --git a/WebHostLib/templates/player-settings.html b/WebHostLib/templates/player-options.html similarity index 50% rename from WebHostLib/templates/player-settings.html rename to WebHostLib/templates/player-options.html index 50b9e3cbb1..4c74975288 100644 --- a/WebHostLib/templates/player-settings.html +++ b/WebHostLib/templates/player-options.html @@ -1,26 +1,26 @@ {% extends 'pageWrapper.html' %} {% block head %} - {{ game }} Settings + {{ game }} Options - + - + {% endblock %} {% block body %} {% include 'header/'+theme+'Header.html' %} -
+
-

Player Settings

+

Player Options

Choose the options you would like to play with! You may generate a single-player game from this page, - or download a settings file you can use to participate in a MultiWorld.

+ or download an options file you can use to participate in a MultiWorld.

- A more advanced settings configuration for all games can be found on the - Weighted Settings page. + A more advanced options configuration for all games can be found on the + Weighted options page.
A list of all games you have generated can be found on the User Content Page.
@@ -28,10 +28,24 @@ template file for this game.

-


- -

+
+
+ + +
+
+ + +
+ +

Game Options

@@ -39,8 +53,8 @@
-
- +
+
diff --git a/WebHostLib/templates/siteMap.html b/WebHostLib/templates/siteMap.html index 562dd3b71b..231ec83e24 100644 --- a/WebHostLib/templates/siteMap.html +++ b/WebHostLib/templates/siteMap.html @@ -24,7 +24,7 @@
  • Supported Games Page
  • Tutorials Page
  • User Content
  • -
  • Weighted Settings Page
  • +
  • Weighted Options Page
  • Game Statistics
  • Glossary
  • @@ -46,11 +46,11 @@ {% endfor %} -

    Game Settings Pages

    +

    Game Options Pages

    diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html index 63b70216d7..6666323c93 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -5,15 +5,35 @@ + {% endblock %} {% block body %} {% include 'header/oceanHeader.html' %}

    Currently Supported Games

    -
    +

    -
    +
    @@ -22,21 +42,21 @@ {% for game_name in worlds | title_sorted %} {% set world = worlds[game_name] %}

    -  {{ game_name }} + {{ game_name }}

    - + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + {% if key_locations and "Universal" not in key_locations %} + + {% endif %} + {% if big_key_locations %} + + {% endif %} + + {% for area in sp_areas %} + + + + {% if key_locations and "Universal" not in key_locations %} + + {% endif %} + {% if big_key_locations %} + + {% endif %} + + {% endfor %} +
    {{ area }}{{ checks_done[area] }} / {{ checks_in_area[area] }} + {{ inventory[small_key_ids[area]] if area in key_locations else '—' }} + + {{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }} +
    +
    + + diff --git a/WebHostLib/templates/checksfinderTracker.html b/WebHostLib/templates/tracker__ChecksFinder.html similarity index 82% rename from WebHostLib/templates/checksfinderTracker.html rename to WebHostLib/templates/tracker__ChecksFinder.html index 5df77f5e74..f0995c8548 100644 --- a/WebHostLib/templates/checksfinderTracker.html +++ b/WebHostLib/templates/tracker__ChecksFinder.html @@ -7,6 +7,11 @@ + {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} + +
    diff --git a/WebHostLib/templates/minecraftTracker.html b/WebHostLib/templates/tracker__Minecraft.html similarity index 94% rename from WebHostLib/templates/minecraftTracker.html rename to WebHostLib/templates/tracker__Minecraft.html index 9f5022b4cc..248f2778bd 100644 --- a/WebHostLib/templates/minecraftTracker.html +++ b/WebHostLib/templates/tracker__Minecraft.html @@ -8,13 +8,18 @@ + {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} + +
    -
    diff --git a/WebHostLib/templates/tracker__OcarinaOfTime.html b/WebHostLib/templates/tracker__OcarinaOfTime.html new file mode 100644 index 0000000000..41b76816cf --- /dev/null +++ b/WebHostLib/templates/tracker__OcarinaOfTime.html @@ -0,0 +1,185 @@ + + + + {{ player_name }}'s Tracker + + + + + + {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    {{ hookshot_length }}
    +
    +
    +
    + +
    {{ bottle_count if bottle_count > 0 else '' }}
    +
    +
    +
    + +
    {{ wallet_size }}
    +
    +
    +
    + +
    Zelda
    +
    +
    +
    + +
    Epona
    +
    +
    +
    + +
    Saria
    +
    +
    +
    + +
    Sun
    +
    +
    +
    + +
    Time
    +
    +
    +
    + +
    Storms
    +
    +
    +
    + +
    {{ token_count }}
    +
    +
    +
    + +
    Min
    +
    +
    +
    + +
    Bol
    +
    +
    +
    + +
    Ser
    +
    +
    +
    + +
    Req
    +
    +
    +
    + +
    Noc
    +
    +
    +
    + +
    Pre
    +
    +
    +
    + +
    {{ piece_count if piece_count > 0 else '' }}
    +
    +
    + + + + + + + + {% for area in checks_done %} + + + + + + + + {% for location in location_info[area] %} + + + + + + + {% endfor %} + + {% endfor %} +
    Items
    {{ area }} {{'▼' if area != 'Total'}}{{ small_key_counts.get(area, '-') }}{{ boss_key_counts.get(area, '-') }}{{ checks_done[area] }} / {{ checks_in_area[area] }}
    {{ location }}{{ '✔' if location_info[area][location] else '' }}
    +
    + + diff --git a/WebHostLib/templates/sc2wolTracker.html b/WebHostLib/templates/tracker__Starcraft2WingsOfLiberty.html similarity index 99% rename from WebHostLib/templates/sc2wolTracker.html rename to WebHostLib/templates/tracker__Starcraft2WingsOfLiberty.html index 49c31a5795..c27f690dfd 100644 --- a/WebHostLib/templates/sc2wolTracker.html +++ b/WebHostLib/templates/tracker__Starcraft2WingsOfLiberty.html @@ -8,6 +8,11 @@ + {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} + +
    diff --git a/WebHostLib/templates/supermetroidTracker.html b/WebHostLib/templates/tracker__SuperMetroid.html similarity index 94% rename from WebHostLib/templates/supermetroidTracker.html rename to WebHostLib/templates/tracker__SuperMetroid.html index 342f75642f..0c64817651 100644 --- a/WebHostLib/templates/supermetroidTracker.html +++ b/WebHostLib/templates/tracker__SuperMetroid.html @@ -7,6 +7,11 @@ + {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} + +
    diff --git a/WebHostLib/templates/timespinnerTracker.html b/WebHostLib/templates/tracker__Timespinner.html similarity index 95% rename from WebHostLib/templates/timespinnerTracker.html rename to WebHostLib/templates/tracker__Timespinner.html index f02ec6daab..b118c33833 100644 --- a/WebHostLib/templates/timespinnerTracker.html +++ b/WebHostLib/templates/tracker__Timespinner.html @@ -7,6 +7,11 @@ + {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} + +
    @@ -51,16 +56,16 @@
    {% if 'DownloadableItems' in options %}
    - {% endif %} + {% endif %}
    {% if 'DownloadableItems' in options %}
    - {% endif %} + {% endif %}
    {% if 'EyeSpy' in options %}
    - {% endif %} + {% endif %}
    diff --git a/WebHostLib/templates/viewSeed.html b/WebHostLib/templates/viewSeed.html index e252fb06a2..a8478c95c3 100644 --- a/WebHostLib/templates/viewSeed.html +++ b/WebHostLib/templates/viewSeed.html @@ -34,7 +34,7 @@ {% endif %}
    -
    Rooms:  + {% call macros.list_rooms(seed.rooms | selectattr("owner", "eq", session["_id"])) %}
  • Create New Room diff --git a/WebHostLib/templates/weighted-settings.html b/WebHostLib/templates/weighted-options.html similarity index 82% rename from WebHostLib/templates/weighted-settings.html rename to WebHostLib/templates/weighted-options.html index 9ce097c37f..032a4eeb90 100644 --- a/WebHostLib/templates/weighted-settings.html +++ b/WebHostLib/templates/weighted-options.html @@ -1,26 +1,26 @@ {% extends 'pageWrapper.html' %} {% block head %} - {{ game }} Settings + {{ game }} Options - + - + {% endblock %} {% block body %} {% include 'header/grassHeader.html' %}
    -

    Weighted Settings

    -

    Weighted Settings allows you to choose how likely a particular option is to be used in game generation. +

    Weighted Options

    +

    Weighted options allow you to choose how likely a particular option is to be used in game generation. The higher an option is weighted, the more likely the option will be chosen. Think of them like entries in a raffle.

    Choose the games and options you would like to play with! You may generate a single-player game from - this page, or download a settings file you can use to participate in a MultiWorld.

    + this page, or download an options file you can use to participate in a MultiWorld.

    A list of all games you have generated can be found on the User Content page.

    @@ -40,7 +40,7 @@
    - +
    diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 0d9ead7951..8a7155afec 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -1,1768 +1,1960 @@ -import collections import datetime -import typing -from typing import Counter, Optional, Dict, Any, Tuple, List +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Set, Tuple from uuid import UUID from flask import render_template -from jinja2 import pass_context, runtime from werkzeug.exceptions import abort from MultiServer import Context, get_saving_second -from NetUtils import ClientStatus, SlotType, NetworkSlot +from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType from Utils import restricted_loads -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 -alttp_icons = { - "Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png", - "Red Shield": r"https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png", - "Mirror Shield": r"https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png", - "Fighter Sword": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920", - "Master Sword": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920", - "Tempered Sword": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920", - "Golden Sword": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920", - "Bow": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c", - "Silver Bow": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920", - "Green Mail": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920", - "Blue Mail": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920", - "Red Mail": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920", - "Power Glove": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920", - "Titan Mitts": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920", - "Progressive Sword": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725", - "Pegasus Boots": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9", - "Progressive Glove": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920", - "Flippers": r"https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920", - "Moon Pearl": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e", - "Progressive Bow": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed", - "Blue Boomerang": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e", - "Red Boomerang": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400", - "Hookshot": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b", - "Mushroom": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59", - "Magic Powder": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png?version=c24e38effbd4f80496d35830ce8ff4ec", - "Fire Rod": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0", - "Ice Rod": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc", - "Bombos": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26", - "Ether": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5", - "Quake": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879", - "Lamp": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce", - "Hammer": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500", - "Shovel": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05", - "Flute": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390", - "Bug Catching Net": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6", - "Book of Mudora": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744", - "Bottle": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b", - "Cane of Somaria": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943", - "Cane of Byrna": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54", - "Cape": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832", - "Magic Mirror": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc", - "Triforce": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48", - "Small Key": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e", - "Big Key": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d", - "Chest": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda", - "Light World": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6", - "Dark World": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc", - "Hyrule Castle": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d3/ALttP_Ball_and_Chain_Trooper_Sprite.png?version=1768a87c06d29cc8e7ddd80b9fa516be", - "Agahnims Tower": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1e/ALttP_Agahnim_Sprite.png?version=365956e61b0c2191eae4eddbe591dab5", - "Desert Palace": r"https://www.zeldadungeon.net/wiki/images/2/25/Lanmola-ALTTP-Sprite.png", - "Eastern Palace": r"https://www.zeldadungeon.net/wiki/images/d/dc/RedArmosKnight.png", - "Tower of Hera": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/ALttP_Moldorm_Sprite.png?version=c588257bdc2543468e008a6b30f262a7", - "Palace of Darkness": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Helmasaur_King_Sprite.png?version=ab8a4a1cfd91d4fc43466c56cba30022", - "Swamp Palace": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Arrghus_Sprite.png?version=b098be3122e53f751b74f4a5ef9184b5", - "Skull Woods": r"https://alttp-wiki.net/images/6/6a/Mothula.png", - "Thieves Town": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/86/ALttP_Blind_the_Thief_Sprite.png?version=3833021bfcd112be54e7390679047222", - "Ice Palace": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Kholdstare_Sprite.png?version=e5a1b0e8b2298e550d85f90bf97045c0", - "Misery Mire": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8", - "Turtle Rock": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be", - "Ganons Tower": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74" -} - - -def get_alttp_id(item_name): - return Items.item_table[item_name][2] - - -links = {"Bow": "Progressive Bow", - "Silver Arrows": "Progressive Bow", - "Silver Bow": "Progressive Bow", - "Progressive Bow (Alt)": "Progressive Bow", - "Bottle (Red Potion)": "Bottle", - "Bottle (Green Potion)": "Bottle", - "Bottle (Blue Potion)": "Bottle", - "Bottle (Fairy)": "Bottle", - "Bottle (Bee)": "Bottle", - "Bottle (Good Bee)": "Bottle", - "Fighter Sword": "Progressive Sword", - "Master Sword": "Progressive Sword", - "Tempered Sword": "Progressive Sword", - "Golden Sword": "Progressive Sword", - "Power Glove": "Progressive Glove", - "Titans Mitts": "Progressive Glove" - } - -levels = {"Fighter Sword": 1, - "Master Sword": 2, - "Tempered Sword": 3, - "Golden Sword": 4, - "Power Glove": 1, - "Titans Mitts": 2, - "Bow": 1, - "Silver Bow": 2} - -multi_items = {get_alttp_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")} -links = {get_alttp_id(key): get_alttp_id(value) for key, value in links.items()} -levels = {get_alttp_id(key): value for key, value in levels.items()} - -tracking_names = ["Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer", - "Hookshot", "Magic Mirror", "Flute", - "Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang", - "Red Boomerang", "Bug Catching Net", "Cape", "Shovel", "Lamp", - "Mushroom", "Magic Powder", - "Cane of Somaria", "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", - "Bottle", "Triforce"] - -default_locations = { - 'Light World': {1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175, - 1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884, - 1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836, - 60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193, - 1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328, - 59881, 59761, 59890, 59770, 193020, 212605}, - 'Dark World': {59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095, - 1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031}, - 'Desert Palace': {1573216, 59842, 59851, 59791, 1573201, 59830}, - 'Eastern Palace': {1573200, 59827, 59893, 59767, 59833, 59773}, - 'Hyrule Castle': {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253}, - 'Agahnims Tower': {60082, 60085}, - 'Tower of Hera': {1573218, 59878, 59821, 1573202, 59896, 59899}, - 'Swamp Palace': {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061}, - 'Thieves Town': {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206}, - 'Skull Woods': {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806}, - 'Ice Palace': {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869}, - 'Misery Mire': {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998}, - 'Turtle Rock': {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935}, - 'Palace of Darkness': {59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995, - 59965}, - 'Ganons Tower': {60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118, - 60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157}, - 'Total': set()} - -key_only_locations = { - 'Light World': set(), - 'Dark World': set(), - 'Desert Palace': {0x140031, 0x14002b, 0x140061, 0x140028}, - 'Eastern Palace': {0x14005b, 0x140049}, - 'Hyrule Castle': {0x140037, 0x140034, 0x14000d, 0x14003d}, - 'Agahnims Tower': {0x140061, 0x140052}, - 'Tower of Hera': set(), - 'Swamp Palace': {0x140019, 0x140016, 0x140013, 0x140010, 0x14000a}, - 'Thieves Town': {0x14005e, 0x14004f}, - 'Skull Woods': {0x14002e, 0x14001c}, - 'Ice Palace': {0x140004, 0x140022, 0x140025, 0x140046}, - 'Misery Mire': {0x140055, 0x14004c, 0x140064}, - 'Turtle Rock': {0x140058, 0x140007}, - 'Palace of Darkness': set(), - 'Ganons Tower': {0x140040, 0x140043, 0x14003a, 0x14001f}, - 'Total': set() -} - -location_to_area = {} -for area, locations in default_locations.items(): - for location in locations: - location_to_area[location] = area - -for area, locations in key_only_locations.items(): - for location in locations: - location_to_area[location] = area - -checks_in_area = {area: len(checks) for area, checks in default_locations.items()} -checks_in_area["Total"] = 216 - -ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace', - 'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', - 'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total") - -tracking_ids = [] - -for item in tracking_names: - tracking_ids.append(get_alttp_id(item)) - -small_key_ids = {} -big_key_ids = {} -ids_small_key = {} -ids_big_key = {} - -for item_name, data in Items.item_table.items(): - if "Key" in item_name: - area = item_name.split("(")[1][:-1] - if "Small" in item_name: - small_key_ids[area] = data[2] - ids_small_key[data[2]] = area - else: - big_key_ids[area] = data[2] - ids_big_key[data[2]] = area - -# cleanup global namespace -del item_name -del data -del item - - -def attribute_item_solo(inventory, item): - """Adds item to inventory counter, converts everything to progressive.""" - target_item = links.get(item, item) - if item in levels: # non-progressive - inventory[target_item] = max(inventory[target_item], levels[item]) - else: - inventory[target_item] += 1 - - -@app.template_filter() -def render_timedelta(delta: datetime.timedelta): - hours, minutes = divmod(delta.total_seconds() / 60, 60) - hours = str(int(hours)) - minutes = str(int(minutes)).zfill(2) - return f"{hours}:{minutes}" - - -@pass_context -def get_location_name(context: runtime.Context, loc: int) -> str: - # once all rooms embed data package, the chain lookup can be dropped - context_locations = context.get("custom_locations", {}) - return collections.ChainMap(context_locations, lookup_any_location_id_to_name).get(loc, loc) - - -@pass_context -def get_item_name(context: runtime.Context, item: int) -> str: - context_items = context.get("custom_items", {}) - return collections.ChainMap(context_items, lookup_any_item_id_to_name).get(item, item) - - -app.jinja_env.filters["location_name"] = get_location_name -app.jinja_env.filters["item_name"] = get_item_name - +# Multisave is currently updated, at most, every minute. +TRACKER_CACHE_TIMEOUT_IN_SECONDS = 60 _multidata_cache = {} +_multiworld_trackers: Dict[str, Callable] = {} +_player_trackers: Dict[str, Callable] = {} + +TeamPlayer = Tuple[int, int] +ItemMetadata = Tuple[int, int, int] -def get_location_table(checks_table: dict) -> dict: - loc_to_area = {} - for area, locations in checks_table.items(): - if area == "Total": - continue - for location in locations: - loc_to_area[location] = area - return loc_to_area +def _cache_results(func: Callable) -> Callable: + """Stores the results of any computationally expensive methods after the initial call in TrackerData. + If called again, returns the cached result instead, as results will not change for the lifetime of TrackerData. + """ + def method_wrapper(self: "TrackerData", *args): + cache_key = f"{func.__name__}{''.join(f'_[{arg.__repr__()}]' for arg in args)}" + if cache_key in self._tracker_cache: + return self._tracker_cache[cache_key] - -def get_static_room_data(room: Room): - result = _multidata_cache.get(room.seed.id, None) - if result: + result = func(self, *args) + self._tracker_cache[cache_key] = result return result - multidata = Context.decompress(room.seed.multidata) - # in > 100 players this can take a bit of time and is the main reason for the cache - locations: Dict[int, Dict[int, Tuple[int, int, int]]] = multidata['locations'] - names: List[List[str]] = multidata.get("names", []) - games = multidata.get("games", {}) - groups = {} - custom_locations = {} - custom_items = {} - if "slot_info" in multidata: - slot_info_dict: Dict[int, NetworkSlot] = multidata["slot_info"] - games = {slot: slot_info.game for slot, slot_info in slot_info_dict.items()} - groups = {slot: slot_info.group_members for slot, slot_info in slot_info_dict.items() - if slot_info.type == SlotType.group} - names = [[slot_info.name for slot, slot_info in sorted(slot_info_dict.items())]] - for game in games.values(): - if game not in multidata["datapackage"]: - continue - game_data = multidata["datapackage"][game] - if "checksum" in game_data: - if network_data_package["games"].get(game, {}).get("checksum") == game_data["checksum"]: - # non-custom. remove from multidata - # network_data_package import could be skipped once all rooms embed data package - del multidata["datapackage"][game] - continue - else: - game_data = restricted_loads(GameDataPackage.get(checksum=game_data["checksum"]).data) - custom_locations.update( - {id_: name for name, id_ in game_data["location_name_to_id"].items()}) - custom_items.update( - {id_: name for name, id_ in game_data["item_name_to_id"].items()}) - seed_checks_in_area = checks_in_area.copy() + return method_wrapper - use_door_tracker = False - if "tags" in multidata: - use_door_tracker = "DR" in multidata["tags"] - if use_door_tracker: - for area, checks in key_only_locations.items(): - seed_checks_in_area[area] += len(checks) - seed_checks_in_area["Total"] = 249 - player_checks_in_area = { - playernumber: { - areaname: len(multidata["checks_in_area"][playernumber][areaname]) if areaname != "Total" else - multidata["checks_in_area"][playernumber]["Total"] - for areaname in ordered_areas +@dataclass +class TrackerData: + """A helper dataclass that is instantiated each time an HTTP request comes in for tracker data. + + Provides helper methods to lazily load necessary data that each tracker require and caches any results so any + subsequent helper method calls do not need to recompute results during the lifetime of this instance. + """ + room: Room + _multidata: Dict[str, Any] + _multisave: Dict[str, Any] + _tracker_cache: Dict[str, Any] + + def __init__(self, room: Room): + """Initialize a new RoomMultidata object for the current room.""" + self.room = room + self._multidata = Context.decompress(room.seed.multidata) + self._multisave = restricted_loads(room.multisave) if room.multisave else {} + self._tracker_cache = {} + + self.item_name_to_id: Dict[str, Dict[str, int]] = {} + self.location_name_to_id: Dict[str, Dict[str, int]] = {} + + # Generate inverse lookup tables from data package, useful for trackers. + self.item_id_to_name: Dict[str, Dict[int, str]] = {} + self.location_id_to_name: Dict[str, Dict[int, str]] = {} + for game, game_package in self._multidata["datapackage"].items(): + game_package = restricted_loads(GameDataPackage.get(checksum=game_package["checksum"]).data) + self.item_id_to_name[game] = {id: name for name, id in game_package["item_name_to_id"].items()} + self.location_id_to_name[game] = {id: name for name, id in game_package["location_name_to_id"].items()} + + # Normal lookup tables as well. + self.item_name_to_id[game] = game_package["item_name_to_id"] + self.location_name_to_id[game] = game_package["item_name_to_id"] + + def get_seed_name(self) -> str: + """Retrieves the seed name.""" + return self._multidata["seed_name"] + + def get_slot_data(self, team: int, player: int) -> Dict[str, Any]: + """Retrieves the slot data for a given player.""" + return self._multidata["slot_data"][player] + + def get_slot_info(self, team: int, player: int) -> NetworkSlot: + """Retrieves the NetworkSlot data for a given player.""" + return self._multidata["slot_info"][player] + + def get_player_name(self, team: int, player: int) -> str: + """Retrieves the slot name for a given player.""" + return self.get_slot_info(team, player).name + + def get_player_game(self, team: int, player: int) -> str: + """Retrieves the game for a given player.""" + return self.get_slot_info(team, player).game + + def get_player_locations(self, team: int, player: int) -> Dict[int, ItemMetadata]: + """Retrieves all locations with their containing item's metadata for a given player.""" + return self._multidata["locations"][player] + + def get_player_starting_inventory(self, team: int, player: int) -> List[int]: + """Retrieves a list of all item codes a given slot starts with.""" + return self._multidata["precollected_items"][player] + + def get_player_checked_locations(self, team: int, player: int) -> Set[int]: + """Retrieves the set of all locations marked complete by this player.""" + return self._multisave.get("location_checks", {}).get((team, player), set()) + + @_cache_results + def get_player_missing_locations(self, team: int, player: int) -> Set[int]: + """Retrieves the set of all locations not marked complete by this player.""" + return set(self.get_player_locations(team, player)) - self.get_player_checked_locations(team, player) + + def get_player_received_items(self, team: int, player: int) -> List[NetworkItem]: + """Returns all items received to this player in order of received.""" + return self._multisave.get("received_items", {}).get((team, player, True), []) + + @_cache_results + def get_player_inventory_counts(self, team: int, player: int) -> Dict[int, int]: + """Retrieves a dictionary of all items received by their id and their received count.""" + items = self.get_player_received_items(team, player) + inventory = {item: 0 for item in self.item_id_to_name[self.get_player_game(team, player)]} + for item in items: + inventory[item.item] += 1 + + return inventory + + @_cache_results + def get_player_hints(self, team: int, player: int) -> Set[Hint]: + """Retrieves a set of all hints relevant for a particular player.""" + return self._multisave.get("hints", {}).get((team, player), set()) + + @_cache_results + def get_player_last_activity(self, team: int, player: int) -> Optional[datetime.timedelta]: + """Retrieves the relative timedelta for when a particular player was last active. + Returns None if no activity was ever recorded. + """ + return self.get_room_last_activity().get((team, player), None) + + def get_player_client_status(self, team: int, player: int) -> ClientStatus: + """Retrieves the ClientStatus of a particular player.""" + return self._multisave.get("client_game_state", {}).get((team, player), ClientStatus.CLIENT_UNKNOWN) + + def get_player_alias(self, team: int, player: int) -> Optional[str]: + """Returns the alias of a particular player, if any.""" + return self._multisave.get("name_aliases", {}).get((team, player), None) + + @_cache_results + def get_team_completed_worlds_count(self) -> Dict[int, int]: + """Retrieves a dictionary of number of completed worlds per team.""" + return { + team: sum( + self.get_player_client_status(team, player) == ClientStatus.CLIENT_GOAL + for player in players if self.get_slot_info(team, player).type == SlotType.player + ) for team, players in self.get_team_players().items() } - for playernumber in multidata["checks_in_area"] - } - player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber]) - for playernumber in multidata["checks_in_area"]} - saving_second = get_saving_second(multidata["seed_name"]) - result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \ - multidata["precollected_items"], games, multidata["slot_data"], groups, saving_second, \ - custom_locations, custom_items - _multidata_cache[room.seed.id] = result - return result + @_cache_results + def get_team_hints(self) -> Dict[int, Set[Hint]]: + """Retrieves a dictionary of all hints per team.""" + hints = {} + for team, players in self.get_team_players().items(): + hints[team] = set() + for player in players: + hints[team] |= self.get_player_hints(team, player) + + return hints + + @_cache_results + def get_team_locations_total_count(self) -> Dict[int, int]: + """Retrieves a dictionary of total player locations each team has.""" + return { + team: sum(len(self.get_player_locations(team, player)) for player in players) + for team, players in self.get_team_players().items() + } + + @_cache_results + def get_team_locations_checked_count(self) -> Dict[int, int]: + """Retrieves a dictionary of checked player locations each team has.""" + return { + team: sum(len(self.get_player_checked_locations(team, player)) for player in players) + for team, players in self.get_team_players().items() + } + + # TODO: Change this method to properly build for each team once teams are properly implemented, as they don't + # currently exist in multidata to easily look up, so these are all assuming only 1 team: Team #0 + @_cache_results + def get_team_players(self) -> Dict[int, List[int]]: + """Retrieves a dictionary of all players ids on each team.""" + return { + 0: [player for player, slot_info in self._multidata["slot_info"].items()] + } + + @_cache_results + def get_room_saving_second(self) -> int: + """Retrieves the saving second value for this seed. + + Useful for knowing when the multisave gets updated so trackers can attempt to update. + """ + return get_saving_second(self.get_seed_name()) + + @_cache_results + def get_room_locations(self) -> Dict[TeamPlayer, Dict[int, ItemMetadata]]: + """Retrieves a dictionary of all locations and their associated item metadata per player.""" + return { + (team, player): self.get_player_locations(team, player) + for team, players in self.get_team_players().items() for player in players + } + + @_cache_results + def get_room_games(self) -> Dict[TeamPlayer, str]: + """Retrieves a dictionary of games for each player.""" + return { + (team, player): self.get_player_game(team, player) + for team, players in self.get_team_players().items() for player in players + } + + @_cache_results + def get_room_locations_complete(self) -> Dict[TeamPlayer, int]: + """Retrieves a dictionary of all locations complete per player.""" + return { + (team, player): len(self.get_player_checked_locations(team, player)) + for team, players in self.get_team_players().items() for player in players + } + + @_cache_results + def get_room_client_statuses(self) -> Dict[TeamPlayer, ClientStatus]: + """Retrieves a dictionary of all ClientStatus values per player.""" + return { + (team, player): self.get_player_client_status(team, player) + for team, players in self.get_team_players().items() for player in players + } + + @_cache_results + def get_room_long_player_names(self) -> Dict[TeamPlayer, str]: + """Retrieves a dictionary of names with aliases for each player.""" + long_player_names = {} + for team, players in self.get_team_players().items(): + for player in players: + alias = self.get_player_alias(team, player) + if alias: + long_player_names[team, player] = f"{alias} ({self.get_player_name(team, player)})" + else: + long_player_names[team, player] = self.get_player_name(team, player) + + return long_player_names + + @_cache_results + def get_room_last_activity(self) -> Dict[TeamPlayer, datetime.timedelta]: + """Retrieves a dictionary of all players and the timedelta from now to their last activity. + Does not include players who have no activity recorded. + """ + last_activity: Dict[TeamPlayer, datetime.timedelta] = {} + now = datetime.datetime.utcnow() + for (team, player), timestamp in self._multisave.get("client_activity_timers", []): + last_activity[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp) + + return last_activity + + @_cache_results + def get_room_videos(self) -> Dict[TeamPlayer, Tuple[str, str]]: + """Retrieves a dictionary of any players who have video streaming enabled and their feeds. + + Only supported platforms are Twitch and YouTube. + """ + video_feeds = {} + for (team, player), video_data in self._multisave.get("video", []): + video_feeds[team, player] = video_data + + return video_feeds -@app.route('/tracker///') -def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False): - key = f"{tracker}_{tracked_team}_{tracked_player}_{want_generic}" +@app.route("/tracker///") +def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> str: + key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}" tracker_page = cache.get(key) if tracker_page: return tracker_page - timeout, tracker_page = _get_player_tracker(tracker, tracked_team, tracked_player, want_generic) + + timeout, tracker_page = get_timeout_and_tracker(tracker, tracked_team, tracked_player, generic) cache.set(key, tracker_page, timeout) return tracker_page -def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool): - # Team and player must be positive and greater than zero - if tracked_team < 0 or tracked_player < 1: - abort(404) - - room: Optional[Room] = Room.get(tracker=tracker) - if not room: - abort(404) - - # Collect seed information and pare it down to a single player - locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ - precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ - get_static_room_data(room) - player_name = names[tracked_team][tracked_player - 1] - location_to_area = player_location_to_area.get(tracked_player, {}) - inventory = collections.Counter() - checks_done = {loc_name: 0 for loc_name in default_locations} - - # Add starting items to inventory - starting_items = precollected_items[tracked_player] - if starting_items: - for item_id in starting_items: - attribute_item_solo(inventory, item_id) - - if room.multisave: - multisave: Dict[str, Any] = restricted_loads(room.multisave) - else: - multisave: Dict[str, Any] = {} - - slots_aimed_at_player = {tracked_player} - for group_id, group_members in groups.items(): - if tracked_player in group_members: - slots_aimed_at_player.add(group_id) - - # Add items to player inventory - for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items(): - # Skip teams and players not matching the request - player_locations = locations[ms_player] - if ms_team == tracked_team: - # If the player does not have the item, do nothing - for location in locations_checked: - if location in player_locations: - item, recipient, flags = player_locations[location] - if recipient in slots_aimed_at_player: # a check done for the tracked player - attribute_item_solo(inventory, item) - if ms_player == tracked_player: # a check done by the tracked player - area_name = location_to_area.get(location, None) - if area_name: - checks_done[area_name] += 1 - checks_done["Total"] += 1 - specific_tracker = game_specific_trackers.get(games[tracked_player], None) - if specific_tracker and not want_generic: - tracker = specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, - seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second) - else: - tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, - player_name, seed_checks_in_area, checks_done, saving_second, - custom_locations, custom_items) - - return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker - - -@app.route('/generic_tracker///') -def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int): +@app.route("/generic_tracker///") +def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> str: return get_player_tracker(tracker, tracked_team, tracked_player, True) -def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, player_name: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, - saving_second: int) -> str: - - # Note the presence of the triforce item - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - if game_state == 30: - inventory[106] = 1 # Triforce - - # Progressive items need special handling for icons and class - progressive_items = { - "Progressive Sword": 94, - "Progressive Glove": 97, - "Progressive Bow": 100, - "Progressive Mail": 96, - "Progressive Shield": 95, - } - progressive_names = { - "Progressive Sword": [None, 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'], - "Progressive Glove": [None, 'Power Glove', 'Titan Mitts'], - "Progressive Bow": [None, "Bow", "Silver Bow"], - "Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"], - "Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"] - } - - # Determine which icon to use - display_data = {} - 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] - acquired = True - if not display_name: - acquired = False - display_name = progressive_names[item_name][level + 1] - base_name = item_name.split(maxsplit=1)[1].lower() - display_data[base_name + "_acquired"] = acquired - display_data[base_name + "_url"] = alttp_icons[display_name] - - # The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should? - sp_areas = ordered_areas[2:15] - - player_big_key_locations = set() - player_small_key_locations = set() - for loc_data in locations.values(): - for values in loc_data.values(): - item_id, item_player, flags = values - if item_player == player: - if item_id in ids_big_key: - player_big_key_locations.add(ids_big_key[item_id]) - elif item_id in ids_small_key: - player_small_key_locations.add(ids_small_key[item_id]) - - return render_template("lttpTracker.html", inventory=inventory, - player_name=player_name, room=room, icons=alttp_icons, checks_done=checks_done, - checks_in_area=seed_checks_in_area[player], - acquired_items={lookup_any_item_id_to_name[id] for id in inventory}, - small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas, - key_locations=player_small_key_locations, - big_key_locations=player_big_key_locations, - **display_data) - - -def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, playerName: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, - saving_second: int) -> str: - - icons = { - "Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png", - "Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png", - "Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png", - "Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png", - "Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png", - "Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png", - "Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png", - "Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png", - "Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png", - "Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png", - "Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png", - "Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png", - "Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png", - "Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png", - "Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png", - "Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png", - "Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png", - "Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png", - "Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png", - "Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png", - "Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png", - "Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif", - "Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png", - "Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif", - "Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png", - "Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png", - "Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png", - "Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png", - "Saddle": "https://i.imgur.com/2QtDyR0.png", - "Channeling Book": "https://i.imgur.com/J3WsYZw.png", - "Silk Touch Book": "https://i.imgur.com/iqERxHQ.png", - "Piercing IV Book": "https://i.imgur.com/OzJptGz.png", - } - - minecraft_location_ids = { - "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, - 42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077], - "Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021, - 42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014], - "The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046], - "Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020, - 42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100], - "Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112, - 42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095], - "Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091], - } - - display_data = {} - - # Determine display for progressive items - progressive_items = { - "Progressive Tools": 45013, - "Progressive Weapons": 45012, - "Progressive Armor": 45014, - "Progressive Resource Crafting": 45001 - } - progressive_names = { - "Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"], - "Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"], - "Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"], - "Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"] - } - 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(' ', '_') - display_data[base_name + "_url"] = icons[display_name] - - # Multi-items - multi_items = { - "3 Ender Pearls": 45029, - "8 Netherite Scrap": 45015, - "Dragon Egg Shard": 45043 - } - for item_name, item_id in multi_items.items(): - base_name = item_name.split()[-1].lower() - count = inventory[item_id] - if count >= 0: - display_data[base_name + "_count"] = count - - # Victory condition - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - display_data['game_finished'] = game_state == 30 - - # Turn location IDs into advancement tab counts - checked_locations = multisave.get("location_checks", {}).get((team, player), set()) - lookup_name = lambda id: lookup_any_location_id_to_name[id] - location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations} - for tab_name, tab_locations in minecraft_location_ids.items()} - checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations]) - for tab_name, tab_locations in minecraft_location_ids.items()} - checks_done['Total'] = len(checked_locations) - checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()} - checks_in_area['Total'] = sum(checks_in_area.values()) - - return render_template("minecraftTracker.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, saving_second = saving_second, - checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, - **display_data) - - -def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, playerName: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, - saving_second: int) -> str: - - icons = { - "Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png", - "Ocarina of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Ocarina_of_Time_Icon.png", - "Slingshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/32/OoT_Fairy_Slingshot_Icon.png", - "Boomerang": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/d5/OoT_Boomerang_Icon.png", - "Bottle": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/fc/OoT_Bottle_Icon.png", - "Rutos Letter": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/OoT_Letter_Icon.png", - "Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/11/OoT_Bomb_Icon.png", - "Bombchus": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/36/OoT_Bombchu_Icon.png", - "Lens of Truth": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/05/OoT_Lens_of_Truth_Icon.png", - "Bow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9a/OoT_Fairy_Bow_Icon.png", - "Hookshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/77/OoT_Hookshot_Icon.png", - "Longshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/OoT_Longshot_Icon.png", - "Megaton Hammer": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/93/OoT_Megaton_Hammer_Icon.png", - "Fire Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1e/OoT_Fire_Arrow_Icon.png", - "Ice Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3c/OoT_Ice_Arrow_Icon.png", - "Light Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/76/OoT_Light_Arrow_Icon.png", - "Dins Fire": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/da/OoT_Din%27s_Fire_Icon.png", - "Farores Wind": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/7a/OoT_Farore%27s_Wind_Icon.png", - "Nayrus Love": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/be/OoT_Nayru%27s_Love_Icon.png", - "Kokiri Sword": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/5/53/OoT_Kokiri_Sword_Icon.png", - "Biggoron Sword": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2e/OoT_Giant%27s_Knife_Icon.png", - "Mirror Shield": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b0/OoT_Mirror_Shield_Icon_2.png", - "Goron Bracelet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b7/OoT_Goron%27s_Bracelet_Icon.png", - "Silver Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b9/OoT_Silver_Gauntlets_Icon.png", - "Golden Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/6a/OoT_Golden_Gauntlets_Icon.png", - "Goron Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1c/OoT_Goron_Tunic_Icon.png", - "Zora Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2c/OoT_Zora_Tunic_Icon.png", - "Silver Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Silver_Scale_Icon.png", - "Gold Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/95/OoT_Golden_Scale_Icon.png", - "Iron Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/34/OoT_Iron_Boots_Icon.png", - "Hover Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/22/OoT_Hover_Boots_Icon.png", - "Adults Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f9/OoT_Adult%27s_Wallet_Icon.png", - "Giants Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/8/87/OoT_Giant%27s_Wallet_Icon.png", - "Small Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9f/OoT3D_Magic_Jar_Icon.png", - "Large Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3e/OoT3D_Large_Magic_Jar_Icon.png", - "Gerudo Membership Card": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Gerudo_Token_Icon.png", - "Gold Skulltula Token": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/47/OoT_Token_Icon.png", - "Triforce Piece": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0b/SS_Triforce_Piece_Icon.png", - "Triforce": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/68/ALttP_Triforce_Title_Sprite.png", - "Zeldas Lullaby": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", - "Eponas Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", - "Sarias Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", - "Suns Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", - "Song of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", - "Song of Storms": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", - "Minuet of Forest": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e4/Green_Note.png", - "Bolero of Fire": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f0/Red_Note.png", - "Serenade of Water": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0f/Blue_Note.png", - "Requiem of Spirit": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/Orange_Note.png", - "Nocturne of Shadow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/Purple_Note.png", - "Prelude of Light": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/90/Yellow_Note.png", - "Small Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e5/OoT_Small_Key_Icon.png", - "Boss Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/40/OoT_Boss_Key_Icon.png", - } - - display_data = {} - - # Determine display for progressive items - progressive_items = { - "Progressive Hookshot": 66128, - "Progressive Strength Upgrade": 66129, - "Progressive Wallet": 66133, - "Progressive Scale": 66134, - "Magic Meter": 66138, - "Ocarina": 66139, - } - - progressive_names = { - "Progressive Hookshot": ["Hookshot", "Hookshot", "Longshot"], - "Progressive Strength Upgrade": ["Goron Bracelet", "Goron Bracelet", "Silver Gauntlets", "Golden Gauntlets"], - "Progressive Wallet": ["Adults Wallet", "Adults Wallet", "Giants Wallet", "Giants Wallet"], - "Progressive Scale": ["Silver Scale", "Silver Scale", "Gold Scale"], - "Magic Meter": ["Small Magic", "Small Magic", "Large Magic"], - "Ocarina": ["Fairy Ocarina", "Fairy Ocarina", "Ocarina of Time"] - } - - 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] - if item_name.startswith("Progressive"): - base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_') - else: - base_name = item_name.lower().replace(' ', '_') - display_data[base_name+"_url"] = icons[display_name] - - if base_name == "hookshot": - display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level) - if base_name == "wallet": - display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level) - - # Determine display for bottles. Show letter if it's obtained, determine bottle count - bottle_ids = [66015, 66020, 66021, 66140, 66141, 66142, 66143, 66144, 66145, 66146, 66147, 66148] - display_data['bottle_count'] = min(sum(map(lambda item_id: inventory[item_id], bottle_ids)), 4) - display_data['bottle_url'] = icons['Rutos Letter'] if inventory[66021] > 0 else icons['Bottle'] - - # Determine bombchu display - display_data['has_bombchus'] = any(map(lambda item_id: inventory[item_id] > 0, [66003, 66106, 66107, 66137])) - - # Multi-items - multi_items = { - "Gold Skulltula Token": 66091, - "Triforce Piece": 66202, - } - for item_name, item_id in multi_items.items(): - base_name = item_name.split()[-1].lower() - display_data[base_name+"_count"] = inventory[item_id] - - # Gather dungeon locations - area_id_ranges = { - "Overworld": ((67000, 67263), (67269, 67280), (67747, 68024), (68054, 68062)), - "Deku Tree": ((67281, 67303), (68063, 68077)), - "Dodongo's Cavern": ((67304, 67334), (68078, 68160)), - "Jabu Jabu's Belly": ((67335, 67359), (68161, 68188)), - "Bottom of the Well": ((67360, 67384), (68189, 68230)), - "Forest Temple": ((67385, 67420), (68231, 68281)), - "Fire Temple": ((67421, 67457), (68282, 68350)), - "Water Temple": ((67458, 67484), (68351, 68483)), - "Shadow Temple": ((67485, 67532), (68484, 68565)), - "Spirit Temple": ((67533, 67582), (68566, 68625)), - "Ice Cavern": ((67583, 67596), (68626, 68649)), - "Gerudo Training Ground": ((67597, 67635), (68650, 68656)), - "Thieves' Hideout": ((67264, 67268), (68025, 68053)), - "Ganon's Castle": ((67636, 67673), (68657, 68705)), - } - - def lookup_and_trim(id, area): - full_name = lookup_any_location_id_to_name[id] - if 'Ganons Tower' in full_name: - return full_name - if area not in ["Overworld", "Thieves' Hideout"]: - # trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC - return full_name[len(area):] - return full_name - - checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player])) - location_info = {} - checks_done = {} - checks_in_area = {} - for area, ranges in area_id_ranges.items(): - location_info[area] = {} - checks_done[area] = 0 - checks_in_area[area] = 0 - for r in ranges: - min_id, max_id = r - for id in range(min_id, max_id+1): - if id in locations[player]: - checked = id in checked_locations - location_info[area][lookup_and_trim(id, area)] = checked - checks_in_area[area] += 1 - checks_done[area] += checked - - checks_done['Total'] = sum(checks_done.values()) - checks_in_area['Total'] = sum(checks_in_area.values()) - - # Give skulltulas on non-tracked locations - non_tracked_locations = multisave.get("location_checks", {}).get((team, player), set()).difference(set(locations[player])) - for id in non_tracked_locations: - if "GS" in lookup_and_trim(id, ''): - display_data["token_count"] += 1 - - oot_y = '✔' - oot_x = '✕' - - # Gather small and boss key info - small_key_counts = { - "Forest Temple": oot_y if inventory[66203] else inventory[66175], - "Fire Temple": oot_y if inventory[66204] else inventory[66176], - "Water Temple": oot_y if inventory[66205] else inventory[66177], - "Spirit Temple": oot_y if inventory[66206] else inventory[66178], - "Shadow Temple": oot_y if inventory[66207] else inventory[66179], - "Bottom of the Well": oot_y if inventory[66208] else inventory[66180], - "Gerudo Training Ground": oot_y if inventory[66209] else inventory[66181], - "Thieves' Hideout": oot_y if inventory[66210] else inventory[66182], - "Ganon's Castle": oot_y if inventory[66211] else inventory[66183], - } - boss_key_counts = { - "Forest Temple": oot_y if inventory[66149] else oot_x, - "Fire Temple": oot_y if inventory[66150] else oot_x, - "Water Temple": oot_y if inventory[66151] else oot_x, - "Spirit Temple": oot_y if inventory[66152] else oot_x, - "Shadow Temple": oot_y if inventory[66153] else oot_x, - "Ganon's Castle": oot_y if inventory[66154] else oot_x, - } - - # Victory condition - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - display_data['game_finished'] = game_state == 30 - - return render_template("ootTracker.html", - inventory=inventory, player=player, team=team, room=room, player_name=playerName, - icons=icons, acquired_items={lookup_any_item_id_to_name[id] for id in inventory}, - checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, - small_key_counts=small_key_counts, boss_key_counts=boss_key_counts, - **display_data) - - -def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, playerName: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], - slot_data: Dict[str, Any], saving_second: int) -> str: - - icons = { - "Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png", - "Timespinner Spindle": "https://timespinnerwiki.com/mediawiki/images/1/1a/Timespinner_Spindle.png", - "Timespinner Gear 1": "https://timespinnerwiki.com/mediawiki/images/3/3c/Timespinner_Gear_1.png", - "Timespinner Gear 2": "https://timespinnerwiki.com/mediawiki/images/e/e9/Timespinner_Gear_2.png", - "Timespinner Gear 3": "https://timespinnerwiki.com/mediawiki/images/2/22/Timespinner_Gear_3.png", - "Talaria Attachment": "https://timespinnerwiki.com/mediawiki/images/6/61/Talaria_Attachment.png", - "Succubus Hairpin": "https://timespinnerwiki.com/mediawiki/images/4/49/Succubus_Hairpin.png", - "Lightwall": "https://timespinnerwiki.com/mediawiki/images/0/03/Lightwall.png", - "Celestial Sash": "https://timespinnerwiki.com/mediawiki/images/f/f1/Celestial_Sash.png", - "Twin Pyramid Key": "https://timespinnerwiki.com/mediawiki/images/4/49/Twin_Pyramid_Key.png", - "Security Keycard D": "https://timespinnerwiki.com/mediawiki/images/1/1b/Security_Keycard_D.png", - "Security Keycard C": "https://timespinnerwiki.com/mediawiki/images/e/e5/Security_Keycard_C.png", - "Security Keycard B": "https://timespinnerwiki.com/mediawiki/images/f/f6/Security_Keycard_B.png", - "Security Keycard A": "https://timespinnerwiki.com/mediawiki/images/b/b9/Security_Keycard_A.png", - "Library Keycard V": "https://timespinnerwiki.com/mediawiki/images/5/50/Library_Keycard_V.png", - "Tablet": "https://timespinnerwiki.com/mediawiki/images/a/a0/Tablet.png", - "Elevator Keycard": "https://timespinnerwiki.com/mediawiki/images/5/55/Elevator_Keycard.png", - "Oculus Ring": "https://timespinnerwiki.com/mediawiki/images/8/8d/Oculus_Ring.png", - "Water Mask": "https://timespinnerwiki.com/mediawiki/images/0/04/Water_Mask.png", - "Gas Mask": "https://timespinnerwiki.com/mediawiki/images/2/2e/Gas_Mask.png", - "Djinn Inferno": "https://timespinnerwiki.com/mediawiki/images/f/f6/Djinn_Inferno.png", - "Pyro Ring": "https://timespinnerwiki.com/mediawiki/images/2/2c/Pyro_Ring.png", - "Infernal Flames": "https://timespinnerwiki.com/mediawiki/images/1/1f/Infernal_Flames.png", - "Fire Orb": "https://timespinnerwiki.com/mediawiki/images/3/3e/Fire_Orb.png", - "Royal Ring": "https://timespinnerwiki.com/mediawiki/images/f/f3/Royal_Ring.png", - "Plasma Geyser": "https://timespinnerwiki.com/mediawiki/images/1/12/Plasma_Geyser.png", - "Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png", - "Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png", - "Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png", - } - - timespinner_location_ids = { - "Present": [ - 1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009, - 1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019, - 1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029, - 1337030, 1337031, 1337032, 1337033, 1337034, 1337035, 1337036, 1337037, 1337038, 1337039, - 1337040, 1337041, 1337042, 1337043, 1337044, 1337045, 1337046, 1337047, 1337048, 1337049, - 1337050, 1337051, 1337052, 1337053, 1337054, 1337055, 1337056, 1337057, 1337058, 1337059, - 1337060, 1337061, 1337062, 1337063, 1337064, 1337065, 1337066, 1337067, 1337068, 1337069, - 1337070, 1337071, 1337072, 1337073, 1337074, 1337075, 1337076, 1337077, 1337078, 1337079, - 1337080, 1337081, 1337082, 1337083, 1337084, 1337085], - "Past": [ - 1337086, 1337087, 1337088, 1337089, - 1337090, 1337091, 1337092, 1337093, 1337094, 1337095, 1337096, 1337097, 1337098, 1337099, - 1337100, 1337101, 1337102, 1337103, 1337104, 1337105, 1337106, 1337107, 1337108, 1337109, - 1337110, 1337111, 1337112, 1337113, 1337114, 1337115, 1337116, 1337117, 1337118, 1337119, - 1337120, 1337121, 1337122, 1337123, 1337124, 1337125, 1337126, 1337127, 1337128, 1337129, - 1337130, 1337131, 1337132, 1337133, 1337134, 1337135, 1337136, 1337137, 1337138, 1337139, - 1337140, 1337141, 1337142, 1337143, 1337144, 1337145, 1337146, 1337147, 1337148, 1337149, - 1337150, 1337151, 1337152, 1337153, 1337154, 1337155, - 1337171, 1337172, 1337173, 1337174, 1337175], - "Ancient Pyramid": [ - 1337236, - 1337246, 1337247, 1337248, 1337249] - } - - if(slot_data["DownloadableItems"]): - timespinner_location_ids["Present"] += [ - 1337156, 1337157, 1337159, - 1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169, - 1337170] - if(slot_data["Cantoran"]): - timespinner_location_ids["Past"].append(1337176) - if(slot_data["LoreChecks"]): - timespinner_location_ids["Present"] += [ - 1337177, 1337178, 1337179, - 1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187] - timespinner_location_ids["Past"] += [ - 1337188, 1337189, - 1337190, 1337191, 1337192, 1337193, 1337194, 1337195, 1337196, 1337197, 1337198] - if(slot_data["GyreArchives"]): - timespinner_location_ids["Ancient Pyramid"] += [ - 1337237, 1337238, 1337239, - 1337240, 1337241, 1337242, 1337243, 1337244, 1337245] - - display_data = {} - - # Victory condition - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - display_data['game_finished'] = game_state == 30 - - # Turn location IDs into advancement tab counts - checked_locations = multisave.get("location_checks", {}).get((team, player), set()) - lookup_name = lambda id: lookup_any_location_id_to_name[id] - location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations} - for tab_name, tab_locations in timespinner_location_ids.items()} - checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations]) - for tab_name, tab_locations in timespinner_location_ids.items()} - checks_done['Total'] = len(checked_locations) - checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in timespinner_location_ids.items()} - checks_in_area['Total'] = sum(checks_in_area.values()) - acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name} - options = {k for k, v in slot_data.items() if v} - - return render_template("timespinnerTracker.html", - inventory=inventory, icons=icons, acquired_items=acquired_items, - player=player, team=team, room=room, player_name=playerName, - checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, - options=options, **display_data) - -def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, playerName: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, - saving_second: int) -> str: - - icons = { - "Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png", - "Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png", - "Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png", - "Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png", - "Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png", - "Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png", - "Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png", - "Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png", - "Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png", - "Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png", - "Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png", - "Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png", - "Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png", - "Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png", - "Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png", - "Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png", - "Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png", - "Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png", - "X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png", - "Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png", - "Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png", - "Nothing": "", - "No Energy": "", - "Kraid": "", - "Phantoon": "", - "Draygon": "", - "Ridley": "", - "Mother Brain": "", - } - - multi_items = { - "Energy Tank": 83000, - "Missile": 83001, - "Super Missile": 83002, - "Power Bomb": 83003, - "Reserve Tank": 83020, - } - - supermetroid_location_ids = { - 'Crateria/Blue Brinstar': [82005, 82007, 82008, 82026, 82029, - 82000, 82004, 82006, 82009, 82010, - 82011, 82012, 82027, 82028, 82034, - 82036, 82037], - 'Green/Pink Brinstar': [82017, 82023, 82030, 82033, 82035, - 82013, 82014, 82015, 82016, 82018, - 82019, 82021, 82022, 82024, 82025, - 82031], - 'Red Brinstar': [82038, 82042, 82039, 82040, 82041], - 'Kraid': [82043, 82048, 82044], - 'Norfair': [82050, 82053, 82061, 82066, 82068, - 82049, 82051, 82054, 82055, 82056, - 82062, 82063, 82064, 82065, 82067], - 'Lower Norfair': [82078, 82079, 82080, 82070, 82071, - 82073, 82074, 82075, 82076, 82077], - 'Crocomire': [82052, 82060, 82057, 82058, 82059], - 'Wrecked Ship': [82129, 82132, 82134, 82135, 82001, - 82002, 82003, 82128, 82130, 82131, - 82133], - 'West Maridia': [82138, 82136, 82137, 82139, 82140, - 82141, 82142], - 'East Maridia': [82143, 82145, 82150, 82152, 82154, - 82144, 82146, 82147, 82148, 82149, - 82151], - } - - display_data = {} - - - for item_name, item_id in multi_items.items(): - base_name = item_name.split()[0].lower() - display_data[base_name+"_count"] = inventory[item_id] - - # Victory condition - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - display_data['game_finished'] = game_state == 30 - - # Turn location IDs into advancement tab counts - checked_locations = multisave.get("location_checks", {}).get((team, player), set()) - lookup_name = lambda id: lookup_any_location_id_to_name[id] - location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations} - for tab_name, tab_locations in supermetroid_location_ids.items()} - checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations]) - for tab_name, tab_locations in supermetroid_location_ids.items()} - checks_done['Total'] = len(checked_locations) - checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()} - checks_in_area['Total'] = sum(checks_in_area.values()) - - return render_template("supermetroidTracker.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) - -def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, playerName: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], - slot_data: Dict, saving_second: int) -> str: - - 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", - "Starting Supply": "https://static.wikia.nocookie.net/starcraft/images/d/d3/TerranSupply_SC2_Icon1.gif", - - "Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png", - "Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png", - "Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png", - "Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png", - "Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png", - "Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png", - "Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png", - "Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png", - "Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png", - "Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png", - "Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png", - "Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png", - "Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png", - "Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png", - "Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png", - "Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png", - "Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png", - "Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png", - - "Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg", - "Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg", - "Sensor Tower": "https://static.wikia.nocookie.net/starcraft/images/d/d2/SensorTower_SC2_Icon1.jpg", - - "Projectile Accelerator (Bunker)": "https://0rganics.org/archipelago/sc2wol/ProjectileAccelerator.png", - "Neosteel Bunker (Bunker)": "https://0rganics.org/archipelago/sc2wol/NeosteelBunker.png", - "Titanium Housing (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/TitaniumHousing.png", - "Hellstorm Batteries (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png", - "Advanced Construction (SCV)": "https://0rganics.org/archipelago/sc2wol/AdvancedConstruction.png", - "Dual-Fusion Welders (SCV)": "https://0rganics.org/archipelago/sc2wol/Dual-FusionWelders.png", - "Fire-Suppression System (Building)": "https://0rganics.org/archipelago/sc2wol/Fire-SuppressionSystem.png", - "Orbital Command (Building)": "https://0rganics.org/archipelago/sc2wol/OrbitalCommandCampaign.png", - - "Marine": "https://static.wikia.nocookie.net/starcraft/images/4/47/Marine_SC2_Icon1.jpg", - "Medic": "https://static.wikia.nocookie.net/starcraft/images/7/74/Medic_SC2_Rend1.jpg", - "Firebat": "https://static.wikia.nocookie.net/starcraft/images/3/3c/Firebat_SC2_Rend1.jpg", - "Marauder": "https://static.wikia.nocookie.net/starcraft/images/b/ba/Marauder_SC2_Icon1.jpg", - "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", - "Goliath": "https://static.wikia.nocookie.net/starcraft/images/e/eb/Goliath_WoL.jpg", - "Diamondback": "https://static.wikia.nocookie.net/starcraft/images/a/a6/Diamondback_WoL.jpg", - "Siege Tank": "https://static.wikia.nocookie.net/starcraft/images/5/57/SiegeTank_SC2_Icon1.jpg", - - "Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png", - "Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.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", - "Viking": "https://static.wikia.nocookie.net/starcraft/images/2/2a/Viking_SC2_Icon1.jpg", - "Banshee": "https://static.wikia.nocookie.net/starcraft/images/3/32/Banshee_SC2_Icon1.jpg", - "Battlecruiser": "https://static.wikia.nocookie.net/starcraft/images/f/f5/Battlecruiser_SC2_Icon1.jpg", - - "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", - "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", - "Hammer Securities": "https://static.wikia.nocookie.net/starcraft/images/3/3b/HammerSecurity_SC2_Icon1.jpg", - "Spartan Company": "https://static.wikia.nocookie.net/starcraft/images/b/be/SpartanCompany_SC2_Icon1.jpg", - "Siege Breakers": "https://static.wikia.nocookie.net/starcraft/images/3/31/SiegeBreakers_SC2_Icon1.jpg", - "Hel's Angel": "https://static.wikia.nocookie.net/starcraft/images/6/63/HelsAngels_SC2_Icon1.jpg", - "Dusk Wings": "https://static.wikia.nocookie.net/starcraft/images/5/52/DuskWings_SC2_Icon1.jpg", - "Jackson's Revenge": "https://static.wikia.nocookie.net/starcraft/images/9/95/JacksonsRevenge_SC2_Icon1.jpg", - - "Ultra-Capacitors": "https://static.wikia.nocookie.net/starcraft/images/2/23/SC2_Lab_Ultra_Capacitors_Icon.png", - "Vanadium Plating": "https://static.wikia.nocookie.net/starcraft/images/6/67/SC2_Lab_VanPlating_Icon.png", - "Orbital Depots": "https://static.wikia.nocookie.net/starcraft/images/0/01/SC2_Lab_Orbital_Depot_Icon.png", - "Micro-Filtering": "https://static.wikia.nocookie.net/starcraft/images/2/20/SC2_Lab_MicroFilter_Icon.png", - "Automated Refinery": "https://static.wikia.nocookie.net/starcraft/images/7/71/SC2_Lab_Auto_Refinery_Icon.png", - "Command Center Reactor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/SC2_Lab_CC_Reactor_Icon.png", - "Raven": "https://static.wikia.nocookie.net/starcraft/images/1/19/SC2_Lab_Raven_Icon.png", - "Science Vessel": "https://static.wikia.nocookie.net/starcraft/images/c/c3/SC2_Lab_SciVes_Icon.png", - "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 (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 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", - - "Zealot": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Icon_Protoss_Zealot.jpg", - "Stalker": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Icon_Protoss_Stalker.jpg", - "High Templar": "https://static.wikia.nocookie.net/starcraft/images/a/a0/Icon_Protoss_High_Templar.jpg", - "Dark Templar": "https://static.wikia.nocookie.net/starcraft/images/9/90/Icon_Protoss_Dark_Templar.jpg", - "Immortal": "https://static.wikia.nocookie.net/starcraft/images/c/c1/Icon_Protoss_Immortal.jpg", - "Colossus": "https://static.wikia.nocookie.net/starcraft/images/4/40/Icon_Protoss_Colossus.jpg", - "Phoenix": "https://static.wikia.nocookie.net/starcraft/images/b/b1/Icon_Protoss_Phoenix.jpg", - "Void Ray": "https://static.wikia.nocookie.net/starcraft/images/1/1d/VoidRay_SC2_Rend1.jpg", - "Carrier": "https://static.wikia.nocookie.net/starcraft/images/2/2c/Icon_Protoss_Carrier.jpg", - - "Nothing": "", - } - sc2wol_location_ids = { - "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, - "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, - "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"], - "Progressive Infantry Armor": ["Infantry Armor Level 1", "Infantry Armor Level 1", "Infantry Armor Level 2", "Infantry Armor Level 3"], - "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 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(' ', '_') - .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 = { - "+15 Starting Minerals": 800 + SC2WOL_ITEM_ID_OFFSET, - "+15 Starting Vespene": 801 + SC2WOL_ITEM_ID_OFFSET, - "+2 Starting Supply": 802 + SC2WOL_ITEM_ID_OFFSET - } - for item_name, item_id in multi_items.items(): - base_name = item_name.split()[-1].lower() - count = inventory[item_id] - if base_name == "supply": - count = count * 2 - display_data[base_name + "_count"] = count - else: - count = count * 15 - display_data[base_name + "_count"] = count - - # Victory condition - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - display_data['game_finished'] = game_state == 30 - - # Turn location IDs into mission objective counts - checked_locations = multisave.get("location_checks", {}).get((team, player), set()) - lookup_name = lambda id: lookup_any_location_id_to_name[id] - location_info = {mission_name: {lookup_name(id): (id in checked_locations) for id in mission_locations if id in set(locations[player])} for mission_name, mission_locations in sc2wol_location_ids.items()} - checks_done = {mission_name: len([id for id in mission_locations if id in checked_locations and id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()} - checks_done['Total'] = len(checked_locations) - checks_in_area = {mission_name: len([id for id in mission_locations if id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()} - 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) - -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, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, saving_second: int) -> str: - - icons = { - "Checks Available": "https://0rganics.org/archipelago/cf/spr_tiles_3.png", - "Map Width": "https://0rganics.org/archipelago/cf/spr_tiles_4.png", - "Map Height": "https://0rganics.org/archipelago/cf/spr_tiles_5.png", - "Map Bombs": "https://0rganics.org/archipelago/cf/spr_tiles_6.png", - - "Nothing": "", - } - - checksfinder_location_ids = { - "Tile 1": 81000, - "Tile 2": 81001, - "Tile 3": 81002, - "Tile 4": 81003, - "Tile 5": 81004, - "Tile 6": 81005, - "Tile 7": 81006, - "Tile 8": 81007, - "Tile 9": 81008, - "Tile 10": 81009, - "Tile 11": 81010, - "Tile 12": 81011, - "Tile 13": 81012, - "Tile 14": 81013, - "Tile 15": 81014, - "Tile 16": 81015, - "Tile 17": 81016, - "Tile 18": 81017, - "Tile 19": 81018, - "Tile 20": 81019, - "Tile 21": 81020, - "Tile 22": 81021, - "Tile 23": 81022, - "Tile 24": 81023, - "Tile 25": 81024, - } - - display_data = {} - - # Multi-items - multi_items = { - "Map Width": 80000, - "Map Height": 80001, - "Map Bombs": 80002 - } - for item_name, item_id in multi_items.items(): - base_name = item_name.split()[-1].lower() - count = inventory[item_id] - display_data[base_name + "_count"] = count - display_data[base_name + "_display"] = count + 5 - - # Get location info - checked_locations = multisave.get("location_checks", {}).get((team, player), set()) - lookup_name = lambda id: lookup_any_location_id_to_name[id] - location_info = {tile_name: {lookup_name(tile_location): (tile_location in checked_locations)} for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in set(locations[player])} - checks_done = {tile_name: len([tile_location]) for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in checked_locations and tile_location in set(locations[player])} - checks_done['Total'] = len(checked_locations) - checks_in_area = checks_done - - # Calculate checks available - display_data["checks_unlocked"] = min(display_data["width_count"] + display_data["height_count"] + display_data["bombs_count"] + 5, 25) - display_data["checks_available"] = max(display_data["checks_unlocked"] - len(checked_locations), 0) - - # Victory condition - game_state = multisave.get("client_game_state", {}).get((team, player), 0) - display_data['game_finished'] = game_state == 30 - - return render_template("checksfinderTracker.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) - -def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], - inventory: Counter, team: int, player: int, playerName: str, - seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], - saving_second: int, custom_locations: Dict[int, str], custom_items: Dict[int, str]) -> str: - - checked_locations = multisave.get("location_checks", {}).get((team, player), set()) - player_received_items = {} - if multisave.get('version', 0) > 0: - ordered_items = multisave.get('received_items', {}).get((team, player, True), []) - else: - ordered_items = multisave.get('received_items', {}).get((team, player), []) - - # add numbering to all items but starter_inventory - for order_index, networkItem in enumerate(ordered_items, start=1): - player_received_items[networkItem.item] = order_index - - return render_template("genericTracker.html", - inventory=inventory, - player=player, team=team, room=room, player_name=playerName, - checked_locations=checked_locations, - not_checked_locations=set(locations[player]) - checked_locations, - received_items=player_received_items, saving_second=saving_second, - custom_items=custom_items, custom_locations=custom_locations) - - -def get_enabled_multiworld_trackers(room: Room, current: str): - enabled = [ - { - "name": "Generic", - "endpoint": "get_multiworld_tracker", - "current": current == "Generic" - } - ] - for game_name, endpoint in multi_trackers.items(): - if any(slot.game == game_name for slot in room.seed.slots) or current == game_name: - enabled.append({ - "name": game_name, - "endpoint": endpoint.__name__, - "current": current == game_name} - ) - return enabled - - -def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]: - room: Room = Room.get(tracker=tracker) +@app.route("/tracker/", defaults={"game": "Generic"}) +@app.route("/tracker//") +@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS) +def get_multiworld_tracker(tracker: UUID, game: str): + # Room must exist. + room = Room.get(tracker=tracker) if not room: - return None + abort(404) - locations, names, use_door_tracker, checks_in_area, player_location_to_area, \ - precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ - get_static_room_data(room) + tracker_data = TrackerData(room) + enabled_trackers = list(get_enabled_multiworld_trackers(room).keys()) + if game not in _multiworld_trackers: + return render_generic_multiworld_tracker(tracker_data, enabled_trackers) - checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations} - for playernumber in range(1, len(team) + 1) if playernumber not in groups} - for teamnumber, team in enumerate(names)} + return _multiworld_trackers[game](tracker_data, enabled_trackers) - percent_total_checks_done = {teamnumber: {playernumber: 0 - for playernumber in range(1, len(team) + 1) if playernumber not in groups} - for teamnumber, team in enumerate(names)} - total_locations = {teamnumber: sum(len(locations[playernumber]) - for playernumber in range(1, len(team) + 1) if playernumber not in groups) - for teamnumber, team in enumerate(names)} +def get_timeout_and_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool) -> Tuple[int, str]: + # Room must exist. + room = Room.get(tracker=tracker) + if not room: + abort(404) - hints = {team: set() for team in range(len(names))} - if room.multisave: - multisave = restricted_loads(room.multisave) + tracker_data = TrackerData(room) + + # Load and render the game-specific player tracker, or fallback to generic tracker if none exists. + game_specific_tracker = _player_trackers.get(tracker_data.get_player_game(tracked_team, tracked_player), None) + if game_specific_tracker and not generic: + tracker = game_specific_tracker(tracker_data, tracked_team, tracked_player) else: - multisave = {} - if "hints" in multisave: - for (team, slot), slot_hints in multisave["hints"].items(): - hints[team] |= set(slot_hints) + tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player) - for (team, player), locations_checked in multisave.get("location_checks", {}).items(): - if player in groups: - continue - player_locations = locations[player] - checks_done[team][player]["Total"] = len(locations_checked) - percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] / - len(player_locations) * 100) \ - if player_locations else 100 + return (tracker_data.get_room_saving_second() - datetime.datetime.now().second) % 60 or 60, tracker - activity_timers = {} - now = datetime.datetime.utcnow() - for (team, player), timestamp in multisave.get("client_activity_timers", []): - activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp) - player_names = {} - completed_worlds = 0 - states: typing.Dict[typing.Tuple[int, int], int] = {} - for team, names in enumerate(names): - 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] == 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(): - player_names[team, player] = alias - long_player_names[(team, player)] = f"{alias} ({long_player_names[team, player]})" +def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]: + # Render the multitracker for any games that exist in the current room if they are defined. + enabled_trackers = {} + for game_name, endpoint in _multiworld_trackers.items(): + if any(slot.game == game_name for slot in room.seed.slots): + enabled_trackers[game_name] = endpoint - video = {} - for (team, player), data in multisave.get("video", []): - video[team, player] = data + # We resort the tracker to have Generic first, then lexicographically each enabled game. + return { + "Generic": render_generic_multiworld_tracker, + **{key: enabled_trackers[key] for key in sorted(enabled_trackers.keys())}, + } - return dict( - player_names=player_names, room=room, checks_done=checks_done, - percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area, - activity_timers=activity_timers, video=video, hints=hints, - long_player_names=long_player_names, - multisave=multisave, precollected_items=precollected_items, groups=groups, - locations=locations, total_locations=total_locations, games=games, states=states, - completed_worlds=completed_worlds, - custom_locations=custom_locations, custom_items=custom_items, + +def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + game = tracker_data.get_player_game(team, player) + + # Add received index to all received items, excluding starting inventory. + received_items_in_order = {} + for received_index, network_item in enumerate(tracker_data.get_player_received_items(team, player), start=1): + received_items_in_order[network_item.item] = received_index + + return render_template( + template_name_or_list="genericTracker.html", + game_specific_tracker=game in _player_trackers, + room=tracker_data.room, + team=team, + player=player, + player_name=tracker_data.get_room_long_player_names()[team, player], + inventory=tracker_data.get_player_inventory_counts(team, player), + locations=tracker_data.get_player_locations(team, player), + checked_locations=tracker_data.get_player_checked_locations(team, player), + received_items=received_items_in_order, + saving_second=tracker_data.get_room_saving_second(), + game=game, + games=tracker_data.get_room_games(), + player_names_with_alias=tracker_data.get_room_long_player_names(), + location_id_to_name=tracker_data.location_id_to_name, + item_id_to_name=tracker_data.item_id_to_name, + hints=tracker_data.get_player_hints(team, player), ) -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"] - - for (team, player), locations_checked in data["multisave"].get("location_checks", {}).items(): - if player in data["groups"]: - continue - player_locations = data["locations"][player] - precollected = data["precollected_items"][player] - for item_id in precollected: - inventory[team][player][item_id] += 1 - for location in locations_checked: - item_id, recipient, flags = player_locations[location] - recipients = groups.get(recipient, [recipient]) - for recipient in recipients: - inventory[team][recipient][item_id] += 1 - return inventory +def render_generic_multiworld_tracker(tracker_data: TrackerData, enabled_trackers: List[str]) -> str: + return render_template( + "multitracker.html", + enabled_trackers=enabled_trackers, + current_tracker="Generic", + room=tracker_data.room, + room_players=tracker_data.get_team_players(), + locations=tracker_data.get_room_locations(), + locations_complete=tracker_data.get_room_locations_complete(), + total_team_locations=tracker_data.get_team_locations_total_count(), + total_team_locations_complete=tracker_data.get_team_locations_checked_count(), + player_names_with_alias=tracker_data.get_room_long_player_names(), + completed_worlds=tracker_data.get_team_completed_worlds_count(), + games=tracker_data.get_room_games(), + states=tracker_data.get_room_client_statuses(), + hints=tracker_data.get_team_hints(), + activity_timers=tracker_data.get_room_last_activity(), + videos=tracker_data.get_room_videos(), + item_id_to_name=tracker_data.item_id_to_name, + location_id_to_name=tracker_data.location_id_to_name, + ) -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 +# TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to +# live in their respective world folders. +import collections - return collections.Counter({mapping.get(item_id, None): count for item_id, count in inventory.items()}) +from worlds import network_data_package -@app.route('/tracker/') -@cache.memoize(timeout=60) # multisave is currently created at most every minute -def get_multiworld_tracker(tracker: UUID): - data = _get_multiworld_tracker_data(tracker) - if not data: - abort(404) +if "Factorio" in network_data_package["games"]: + def render_Factorio_multiworld_tracker(tracker_data: TrackerData, enabled_trackers: List[str]): + inventories: Dict[TeamPlayer, Dict[int, int]] = { + (team, player): { + tracker_data.item_id_to_name["Factorio"][item_id]: count + for item_id, count in tracker_data.get_player_inventory_counts(team, player).items() + } for team, players in tracker_data.get_team_players().items() for player in players + if tracker_data.get_player_game(team, player) == "Factorio" + } - data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic") + return render_template( + "multitracker__Factorio.html", + enabled_trackers=enabled_trackers, + current_tracker="Factorio", + room=tracker_data.room, + room_players=tracker_data.get_team_players(), + locations=tracker_data.get_room_locations(), + locations_complete=tracker_data.get_room_locations_complete(), + total_team_locations=tracker_data.get_team_locations_total_count(), + total_team_locations_complete=tracker_data.get_team_locations_checked_count(), + player_names_with_alias=tracker_data.get_room_long_player_names(), + completed_worlds=tracker_data.get_team_completed_worlds_count(), + games=tracker_data.get_room_games(), + states=tracker_data.get_room_client_statuses(), + hints=tracker_data.get_team_hints(), + activity_timers=tracker_data.get_room_last_activity(), + videos=tracker_data.get_room_videos(), + item_id_to_name=tracker_data.item_id_to_name, + location_id_to_name=tracker_data.location_id_to_name, + inventories=inventories, + ) - return render_template("multiTracker.html", **data) + _multiworld_trackers["Factorio"] = render_Factorio_multiworld_tracker -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) +if "A Link to the Past" in network_data_package["games"]: + def render_ALinkToThePast_multiworld_tracker(tracker_data: TrackerData, enabled_trackers: List[str]): + # Helper objects. + alttp_id_lookup = tracker_data.item_name_to_id["A Link to the Past"] - 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") + multi_items = { + alttp_id_lookup[name] + for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove", "Triforce Piece") + } + links = { + "Bow": "Progressive Bow", + "Silver Arrows": "Progressive Bow", + "Silver Bow": "Progressive Bow", + "Progressive Bow (Alt)": "Progressive Bow", + "Bottle (Red Potion)": "Bottle", + "Bottle (Green Potion)": "Bottle", + "Bottle (Blue Potion)": "Bottle", + "Bottle (Fairy)": "Bottle", + "Bottle (Bee)": "Bottle", + "Bottle (Good Bee)": "Bottle", + "Fighter Sword": "Progressive Sword", + "Master Sword": "Progressive Sword", + "Tempered Sword": "Progressive Sword", + "Golden Sword": "Progressive Sword", + "Power Glove": "Progressive Glove", + "Titans Mitts": "Progressive Glove", + } + links = {alttp_id_lookup[key]: alttp_id_lookup[value] for key, value in links.items()} + levels = { + "Fighter Sword": 1, + "Master Sword": 2, + "Tempered Sword": 3, + "Golden Sword": 4, + "Power Glove": 1, + "Titans Mitts": 2, + "Bow": 1, + "Silver Bow": 2, + "Triforce Piece": 90, + } + tracking_names = [ + "Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer", "Hookshot", "Magic Mirror", "Flute", + "Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang", "Red Boomerang", + "Bug Catching Net", "Cape", "Shovel", "Lamp", "Mushroom", "Magic Powder", "Cane of Somaria", + "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", "Bottle", "Triforce Piece", "Triforce", + ] + default_locations = { + "Light World": { + 1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175, + 1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884, + 1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836, + 60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193, + 1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328, + 59881, 59761, 59890, 59770, 193020, 212605 + }, + "Dark World": { + 59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095, + 1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031 + }, + "Desert Palace": {1573216, 59842, 59851, 59791, 1573201, 59830}, + "Eastern Palace": {1573200, 59827, 59893, 59767, 59833, 59773}, + "Hyrule Castle": {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253}, + "Agahnims Tower": {60082, 60085}, + "Tower of Hera": {1573218, 59878, 59821, 1573202, 59896, 59899}, + "Swamp Palace": {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061}, + "Thieves Town": {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206}, + "Skull Woods": {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806}, + "Ice Palace": {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869}, + "Misery Mire": {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998}, + "Turtle Rock": {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935}, + "Palace of Darkness": { + 59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995, + 59965 + }, + "Ganons Tower": { + 60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118, + 60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157 + }, + "Total": set() + } + key_only_locations = { + "Light World": set(), + "Dark World": set(), + "Desert Palace": {0x140031, 0x14002b, 0x140061, 0x140028}, + "Eastern Palace": {0x14005b, 0x140049}, + "Hyrule Castle": {0x140037, 0x140034, 0x14000d, 0x14003d}, + "Agahnims Tower": {0x140061, 0x140052}, + "Tower of Hera": set(), + "Swamp Palace": {0x140019, 0x140016, 0x140013, 0x140010, 0x14000a}, + "Thieves Town": {0x14005e, 0x14004f}, + "Skull Woods": {0x14002e, 0x14001c}, + "Ice Palace": {0x140004, 0x140022, 0x140025, 0x140046}, + "Misery Mire": {0x140055, 0x14004c, 0x140064}, + "Turtle Rock": {0x140058, 0x140007}, + "Palace of Darkness": set(), + "Ganons Tower": {0x140040, 0x140043, 0x14003a, 0x14001f}, + "Total": set() + } + location_to_area = {} + for area, locations in default_locations.items(): + for location in locations: + location_to_area[location] = area + for area, locations in key_only_locations.items(): + for location in locations: + location_to_area[location] = area - return render_template("multiFactorioTracker.html", **data) + checks_in_area = {area: len(checks) for area, checks in default_locations.items()} + checks_in_area["Total"] = 216 + ordered_areas = ( + "Light World", "Dark World", "Hyrule Castle", "Agahnims Tower", "Eastern Palace", "Desert Palace", + "Tower of Hera", "Palace of Darkness", "Swamp Palace", "Skull Woods", "Thieves Town", "Ice Palace", + "Misery Mire", "Turtle Rock", "Ganons Tower", "Total" + ) + player_checks_in_area = { + (team, player): { + area_name: len(tracker_data._multidata["checks_in_area"][player][area_name]) + if area_name != "Total" else tracker_data._multidata["checks_in_area"][player]["Total"] + for area_name in ordered_areas + } + for team, players in tracker_data.get_team_players().items() + for player in players + if tracker_data.get_slot_info(team, player).type != SlotType.group and + tracker_data.get_slot_info(team, player).game == "A Link to the Past" + } -@app.route('/tracker//A Link to the Past') -@cache.memoize(timeout=60) # multisave is currently created at most every minute -def get_LttP_multiworld_tracker(tracker: UUID): - room: Room = Room.get(tracker=tracker) - if not room: - abort(404) - locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ - precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ - get_static_room_data(room) + tracking_ids = [] + for item in tracking_names: + tracking_ids.append(alttp_id_lookup[item]) - inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if - playernumber not in groups} - for teamnumber, team in enumerate(names)} + # Can't wait to get this into the apworld. Oof. + from worlds.alttp import Items - checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations} - for playernumber in range(1, len(team) + 1) if playernumber not in groups} - for teamnumber, team in enumerate(names)} + small_key_ids = {} + big_key_ids = {} + ids_small_key = {} + ids_big_key = {} + for item_name, data in Items.item_table.items(): + if "Key" in item_name: + area = item_name.split("(")[1][:-1] + if "Small" in item_name: + small_key_ids[area] = data[2] + ids_small_key[data[2]] = area + else: + big_key_ids[area] = data[2] + ids_big_key[data[2]] = area - percent_total_checks_done = {teamnumber: {playernumber: 0 - for playernumber in range(1, len(team) + 1) if playernumber not in groups} - for teamnumber, team in enumerate(names)} + def _get_location_table(checks_table: dict) -> dict: + loc_to_area = {} + for area, locations in checks_table.items(): + if area == "Total": + continue + for location in locations: + loc_to_area[location] = area + return loc_to_area - hints = {team: set() for team in range(len(names))} - if room.multisave: - multisave = restricted_loads(room.multisave) - else: - multisave = {} - if "hints" in multisave: - for (team, slot), slot_hints in multisave["hints"].items(): - hints[team] |= set(slot_hints) + player_location_to_area = { + (team, player): _get_location_table(tracker_data._multidata["checks_in_area"][player]) + for team, players in tracker_data.get_team_players().items() + for player in players + if tracker_data.get_slot_info(team, player).type != SlotType.group and + tracker_data.get_slot_info(team, player).game == "A Link to the Past" + } - def attribute_item(team: int, recipient: int, item: int): - nonlocal inventory - target_item = links.get(item, item) - if item in levels: # non-progressive - inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item]) - else: - inventory[team][recipient][target_item] += 1 + checks_done: Dict[TeamPlayer, Dict[str: int]] = { + (team, player): {location_name: 0 for location_name in default_locations} + for team, players in tracker_data.get_team_players().items() + for player in players + if tracker_data.get_slot_info(team, player).type != SlotType.group and + tracker_data.get_slot_info(team, player).game == "A Link to the Past" + } - for (team, player), locations_checked in multisave.get("location_checks", {}).items(): - if player in groups: - continue - player_locations = locations[player] - if precollected_items: - precollected = precollected_items[player] - 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.get(player, {}): - continue - item, recipient, flags = player_locations[location] - recipients = groups.get(recipient, [recipient]) - for recipient in recipients: - attribute_item(team, recipient, item) - checks_done[team][player][player_location_to_area[player][location]] += 1 - checks_done[team][player]["Total"] += 1 - percent_total_checks_done[team][player] = int( - checks_done[team][player]["Total"] / len(player_locations) * 100) if \ - player_locations else 100 + inventories: Dict[TeamPlayer, Dict[int, int]] = {} + player_big_key_locations = {(player): set() for player in tracker_data.get_team_players()[0]} + player_small_key_locations = {player: set() for player in tracker_data.get_team_players()[0]} + group_big_key_locations = set() + group_key_locations = set() - for (team, player), game_state in multisave.get("client_game_state", {}).items(): - if player in groups: - continue - if game_state == 30: - inventory[team][player][106] = 1 # Triforce + for (team, player), locations in checks_done.items(): + # Check if game complete. + if tracker_data.get_player_client_status(team, player) == ClientStatus.CLIENT_GOAL: + inventories[team, player][106] = 1 # Triforce - player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)} - player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)} - for loc_data in locations.values(): - for values in loc_data.values(): - item_id, item_player, flags = values + # Count number of locations checked. + for location in tracker_data.get_player_checked_locations(team, player): + checks_done[team, player][player_location_to_area[team, player][location]] += 1 + checks_done[team, player]["Total"] += 1 + # Count keys. + for location, (item, receiving, _) in tracker_data.get_player_locations(team, player).items(): + if item in ids_big_key: + player_big_key_locations[receiving].add(ids_big_key[item]) + elif item in ids_small_key: + player_small_key_locations[receiving].add(ids_small_key[item]) + + # Iterate over received items and build inventory/key counts. + inventories[team, player] = collections.Counter() + for network_item in tracker_data.get_player_received_items(team, player): + target_item = links.get(network_item.item, network_item.item) + if network_item.item in levels: # non-progressive + inventories[team, player][target_item] = (max(inventories[team, player][target_item], levels[network_item.item])) + else: + inventories[team, player][target_item] += 1 + + group_key_locations |= player_small_key_locations[player] + group_big_key_locations |= player_big_key_locations[player] + + return render_template( + "multitracker__ALinkToThePast.html", + enabled_trackers=enabled_trackers, + current_tracker="A Link to the Past", + room=tracker_data.room, + room_players=tracker_data.get_team_players(), + locations=tracker_data.get_room_locations(), + locations_complete=tracker_data.get_room_locations_complete(), + total_team_locations=tracker_data.get_team_locations_total_count(), + total_team_locations_complete=tracker_data.get_team_locations_checked_count(), + player_names_with_alias=tracker_data.get_room_long_player_names(), + completed_worlds=tracker_data.get_team_completed_worlds_count(), + games=tracker_data.get_room_games(), + states=tracker_data.get_room_client_statuses(), + hints=tracker_data.get_team_hints(), + activity_timers=tracker_data.get_room_last_activity(), + videos=tracker_data.get_room_videos(), + item_id_to_name=tracker_data.item_id_to_name, + location_id_to_name=tracker_data.location_id_to_name, + inventories=inventories, + tracking_names=tracking_names, + tracking_ids=tracking_ids, + multi_items=multi_items, + checks_done=checks_done, + ordered_areas=ordered_areas, + checks_in_area=player_checks_in_area, + key_locations=group_key_locations, + big_key_locations=group_big_key_locations, + small_key_ids=small_key_ids, + big_key_ids=big_key_ids, + ) + + def render_ALinkToThePast_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + # Helper objects. + alttp_id_lookup = tracker_data.item_name_to_id["A Link to the Past"] + + links = { + "Bow": "Progressive Bow", + "Silver Arrows": "Progressive Bow", + "Silver Bow": "Progressive Bow", + "Progressive Bow (Alt)": "Progressive Bow", + "Bottle (Red Potion)": "Bottle", + "Bottle (Green Potion)": "Bottle", + "Bottle (Blue Potion)": "Bottle", + "Bottle (Fairy)": "Bottle", + "Bottle (Bee)": "Bottle", + "Bottle (Good Bee)": "Bottle", + "Fighter Sword": "Progressive Sword", + "Master Sword": "Progressive Sword", + "Tempered Sword": "Progressive Sword", + "Golden Sword": "Progressive Sword", + "Power Glove": "Progressive Glove", + "Titans Mitts": "Progressive Glove", + } + links = {alttp_id_lookup[key]: alttp_id_lookup[value] for key, value in links.items()} + levels = { + "Fighter Sword": 1, + "Master Sword": 2, + "Tempered Sword": 3, + "Golden Sword": 4, + "Power Glove": 1, + "Titans Mitts": 2, + "Bow": 1, + "Silver Bow": 2, + "Triforce Piece": 90, + } + tracking_names = [ + "Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer", "Hookshot", "Magic Mirror", "Flute", + "Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang", "Red Boomerang", + "Bug Catching Net", "Cape", "Shovel", "Lamp", "Mushroom", "Magic Powder", "Cane of Somaria", + "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", "Bottle", "Triforce Piece", "Triforce", + ] + default_locations = { + "Light World": { + 1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175, + 1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884, + 1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836, + 60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193, + 1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328, + 59881, 59761, 59890, 59770, 193020, 212605 + }, + "Dark World": { + 59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095, + 1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031 + }, + "Desert Palace": {1573216, 59842, 59851, 59791, 1573201, 59830}, + "Eastern Palace": {1573200, 59827, 59893, 59767, 59833, 59773}, + "Hyrule Castle": {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253}, + "Agahnims Tower": {60082, 60085}, + "Tower of Hera": {1573218, 59878, 59821, 1573202, 59896, 59899}, + "Swamp Palace": {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061}, + "Thieves Town": {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206}, + "Skull Woods": {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806}, + "Ice Palace": {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869}, + "Misery Mire": {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998}, + "Turtle Rock": {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935}, + "Palace of Darkness": { + 59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995, + 59965 + }, + "Ganons Tower": { + 60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118, + 60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157 + }, + "Total": set() + } + key_only_locations = { + "Light World": set(), + "Dark World": set(), + "Desert Palace": {0x140031, 0x14002b, 0x140061, 0x140028}, + "Eastern Palace": {0x14005b, 0x140049}, + "Hyrule Castle": {0x140037, 0x140034, 0x14000d, 0x14003d}, + "Agahnims Tower": {0x140061, 0x140052}, + "Tower of Hera": set(), + "Swamp Palace": {0x140019, 0x140016, 0x140013, 0x140010, 0x14000a}, + "Thieves Town": {0x14005e, 0x14004f}, + "Skull Woods": {0x14002e, 0x14001c}, + "Ice Palace": {0x140004, 0x140022, 0x140025, 0x140046}, + "Misery Mire": {0x140055, 0x14004c, 0x140064}, + "Turtle Rock": {0x140058, 0x140007}, + "Palace of Darkness": set(), + "Ganons Tower": {0x140040, 0x140043, 0x14003a, 0x14001f}, + "Total": set() + } + location_to_area = {} + for area, locations in default_locations.items(): + for checked_location in locations: + location_to_area[checked_location] = area + for area, locations in key_only_locations.items(): + for checked_location in locations: + location_to_area[checked_location] = area + + checks_in_area = {area: len(checks) for area, checks in default_locations.items()} + checks_in_area["Total"] = 216 + ordered_areas = ( + "Light World", "Dark World", "Hyrule Castle", "Agahnims Tower", "Eastern Palace", "Desert Palace", + "Tower of Hera", "Palace of Darkness", "Swamp Palace", "Skull Woods", "Thieves Town", "Ice Palace", + "Misery Mire", "Turtle Rock", "Ganons Tower", "Total" + ) + + tracking_ids = [] + for item in tracking_names: + tracking_ids.append(alttp_id_lookup[item]) + + # Can't wait to get this into the apworld. Oof. + from worlds.alttp import Items + + small_key_ids = {} + big_key_ids = {} + ids_small_key = {} + ids_big_key = {} + for item_name, data in Items.item_table.items(): + if "Key" in item_name: + area = item_name.split("(")[1][:-1] + if "Small" in item_name: + small_key_ids[area] = data[2] + ids_small_key[data[2]] = area + else: + big_key_ids[area] = data[2] + ids_big_key[data[2]] = area + + inventory = collections.Counter() + checks_done = {loc_name: 0 for loc_name in default_locations} + player_big_key_locations = set() + player_small_key_locations = set() + + player_locations = tracker_data.get_player_locations(team, player) + for checked_location in tracker_data.get_player_checked_locations(team, player): + if checked_location in player_locations: + area_name = location_to_area.get(checked_location, None) + if area_name: + checks_done[area_name] += 1 + + checks_done["Total"] += 1 + + for received_item in tracker_data.get_player_received_items(team, player): + target_item = links.get(received_item.item, received_item.item) + if received_item.item in levels: # non-progressive + inventory[target_item] = max(inventory[target_item], levels[received_item.item]) + else: + inventory[target_item] += 1 + + for location, (item_id, _, _) in player_locations.items(): if item_id in ids_big_key: - player_big_key_locations[item_player].add(ids_big_key[item_id]) + player_big_key_locations.add(ids_big_key[item_id]) elif item_id in ids_small_key: - player_small_key_locations[item_player].add(ids_small_key[item_id]) - group_big_key_locations = set() - group_key_locations = set() - for player in [player for player in range(1, len(names[0]) + 1) if player not in groups]: - group_key_locations |= player_small_key_locations[player] - group_big_key_locations |= player_big_key_locations[player] + player_small_key_locations.add(ids_small_key[item_id]) - activity_timers = {} - now = datetime.datetime.utcnow() - for (team, player), timestamp in multisave.get("client_activity_timers", []): - activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp) + # Note the presence of the triforce item + if tracker_data.get_player_client_status(team, player) == ClientStatus.CLIENT_GOAL: + inventory[106] = 1 # Triforce - player_names = {} - for team, names in enumerate(names): - for player, name in enumerate(names, 1): - player_names[(team, player)] = name - long_player_names = player_names.copy() - for (team, player), alias in multisave.get("name_aliases", {}).items(): - player_names[(team, player)] = alias - long_player_names[(team, player)] = f"{alias} ({long_player_names[(team, player)]})" + # Progressive items need special handling for icons and class + progressive_items = { + "Progressive Sword": 94, + "Progressive Glove": 97, + "Progressive Bow": 100, + "Progressive Mail": 96, + "Progressive Shield": 95, + } + progressive_names = { + "Progressive Sword": [None, "Fighter Sword", "Master Sword", "Tempered Sword", "Golden Sword"], + "Progressive Glove": [None, "Power Glove", "Titan Mitts"], + "Progressive Bow": [None, "Bow", "Silver Bow"], + "Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"], + "Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"] + } - video = {} - for (team, player), data in multisave.get("video", []): - video[(team, player)] = data + # Determine which icon to use + display_data = {} + 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] + acquired = True + if not display_name: + acquired = False + display_name = progressive_names[item_name][level + 1] + base_name = item_name.split(maxsplit=1)[1].lower() + display_data[base_name + "_acquired"] = acquired + display_data[base_name + "_icon"] = display_name - enabled_multiworld_trackers = get_enabled_multiworld_trackers(room, "A Link to the Past") + # The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should? + sp_areas = ordered_areas[2:15] - return render_template("lttpMultiTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, - lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, - tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, - multi_items=multi_items, checks_done=checks_done, - percent_total_checks_done=percent_total_checks_done, - ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area, - activity_timers=activity_timers, - key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids, - video=video, big_key_locations=group_big_key_locations, - hints=hints, long_player_names=long_player_names, - enabled_multiworld_trackers=enabled_multiworld_trackers) + return render_template( + template_name_or_list="tracker__ALinkToThePast.html", + room=tracker_data.room, + team=team, + player=player, + inventory=inventory, + player_name=tracker_data.get_player_name(team, player), + checks_done=checks_done, + checks_in_area=checks_in_area, + acquired_items={tracker_data.item_id_to_name["A Link to the Past"][id] for id in inventory}, + sp_areas=sp_areas, + small_key_ids=small_key_ids, + key_locations=player_small_key_locations, + big_key_ids=big_key_ids, + big_key_locations=player_big_key_locations, + **display_data, + ) + _multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker + _player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker -game_specific_trackers: typing.Dict[str, typing.Callable] = { - "Minecraft": __renderMinecraftTracker, - "Ocarina of Time": __renderOoTTracker, - "Timespinner": __renderTimespinnerTracker, - "A Link to the Past": __renderAlttpTracker, - "ChecksFinder": __renderChecksfinder, - "Super Metroid": __renderSuperMetroidTracker, - "Starcraft 2 Wings of Liberty": __renderSC2WoLTracker -} +if "Minecraft" in network_data_package["games"]: + def render_Minecraft_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + icons = { + "Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png", + "Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png", + "Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png", + "Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png", + "Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png", + "Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png", + "Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png", + "Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png", + "Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png", + "Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png", + "Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png", + "Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png", + "Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png", + "Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png", + "Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png", + "Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png", + "Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png", + "Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png", + "Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png", + "Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png", + "Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png", + "Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif", + "Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png", + "Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif", + "Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png", + "Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png", + "Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png", + "Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png", + "Saddle": "https://i.imgur.com/2QtDyR0.png", + "Channeling Book": "https://i.imgur.com/J3WsYZw.png", + "Silk Touch Book": "https://i.imgur.com/iqERxHQ.png", + "Piercing IV Book": "https://i.imgur.com/OzJptGz.png", + } -multi_trackers: typing.Dict[str, typing.Callable] = { - "A Link to the Past": get_LttP_multiworld_tracker, -} + minecraft_location_ids = { + "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, + 42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077], + "Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021, + 42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014], + "The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046], + "Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020, + 42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, + 42099, 42103, 42110, 42100], + "Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, + 42112, + 42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095], + "Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091], + } -if "Factorio" in games: - multi_trackers["Factorio"] = get_Factorio_multiworld_tracker + display_data = {} + + # Determine display for progressive items + progressive_items = { + "Progressive Tools": 45013, + "Progressive Weapons": 45012, + "Progressive Armor": 45014, + "Progressive Resource Crafting": 45001 + } + progressive_names = { + "Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"], + "Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"], + "Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"], + "Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"] + } + + inventory = tracker_data.get_player_inventory_counts(team, player) + 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(" ", "_") + display_data[base_name + "_url"] = icons[display_name] + + # Multi-items + multi_items = { + "3 Ender Pearls": 45029, + "8 Netherite Scrap": 45015, + "Dragon Egg Shard": 45043 + } + for item_name, item_id in multi_items.items(): + base_name = item_name.split()[-1].lower() + count = inventory[item_id] + if count >= 0: + display_data[base_name + "_count"] = count + + # Victory condition + game_state = tracker_data.get_player_client_status(team, player) + display_data["game_finished"] = game_state == 30 + + # Turn location IDs into advancement tab counts + checked_locations = tracker_data.get_player_checked_locations(team, player) + lookup_name = lambda id: tracker_data.location_id_to_name["Minecraft"][id] + location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations} + for tab_name, tab_locations in minecraft_location_ids.items()} + checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations]) + for tab_name, tab_locations in minecraft_location_ids.items()} + checks_done["Total"] = len(checked_locations) + checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()} + checks_in_area["Total"] = sum(checks_in_area.values()) + + lookup_any_item_id_to_name = tracker_data.item_id_to_name["Minecraft"] + return render_template( + "tracker__Minecraft.html", + inventory=inventory, + icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0}, + player=player, + team=team, + room=tracker_data.room, + player_name=tracker_data.get_player_name(team, player), + saving_second=tracker_data.get_room_saving_second(), + checks_done=checks_done, + checks_in_area=checks_in_area, + location_info=location_info, + **display_data, + ) + + _player_trackers["Minecraft"] = render_Minecraft_tracker + +if "Ocarina of Time" in network_data_package["games"]: + def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + icons = { + "Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png", + "Ocarina of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Ocarina_of_Time_Icon.png", + "Slingshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/32/OoT_Fairy_Slingshot_Icon.png", + "Boomerang": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/d5/OoT_Boomerang_Icon.png", + "Bottle": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/fc/OoT_Bottle_Icon.png", + "Rutos Letter": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/OoT_Letter_Icon.png", + "Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/11/OoT_Bomb_Icon.png", + "Bombchus": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/36/OoT_Bombchu_Icon.png", + "Lens of Truth": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/05/OoT_Lens_of_Truth_Icon.png", + "Bow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9a/OoT_Fairy_Bow_Icon.png", + "Hookshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/77/OoT_Hookshot_Icon.png", + "Longshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/OoT_Longshot_Icon.png", + "Megaton Hammer": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/93/OoT_Megaton_Hammer_Icon.png", + "Fire Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1e/OoT_Fire_Arrow_Icon.png", + "Ice Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3c/OoT_Ice_Arrow_Icon.png", + "Light Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/76/OoT_Light_Arrow_Icon.png", + "Dins Fire": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/da/OoT_Din%27s_Fire_Icon.png", + "Farores Wind": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/7a/OoT_Farore%27s_Wind_Icon.png", + "Nayrus Love": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/be/OoT_Nayru%27s_Love_Icon.png", + "Kokiri Sword": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/5/53/OoT_Kokiri_Sword_Icon.png", + "Biggoron Sword": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2e/OoT_Giant%27s_Knife_Icon.png", + "Mirror Shield": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b0/OoT_Mirror_Shield_Icon_2.png", + "Goron Bracelet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b7/OoT_Goron%27s_Bracelet_Icon.png", + "Silver Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b9/OoT_Silver_Gauntlets_Icon.png", + "Golden Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/6a/OoT_Golden_Gauntlets_Icon.png", + "Goron Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1c/OoT_Goron_Tunic_Icon.png", + "Zora Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2c/OoT_Zora_Tunic_Icon.png", + "Silver Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Silver_Scale_Icon.png", + "Gold Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/95/OoT_Golden_Scale_Icon.png", + "Iron Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/34/OoT_Iron_Boots_Icon.png", + "Hover Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/22/OoT_Hover_Boots_Icon.png", + "Adults Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f9/OoT_Adult%27s_Wallet_Icon.png", + "Giants Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/8/87/OoT_Giant%27s_Wallet_Icon.png", + "Small Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9f/OoT3D_Magic_Jar_Icon.png", + "Large Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3e/OoT3D_Large_Magic_Jar_Icon.png", + "Gerudo Membership Card": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Gerudo_Token_Icon.png", + "Gold Skulltula Token": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/47/OoT_Token_Icon.png", + "Triforce Piece": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0b/SS_Triforce_Piece_Icon.png", + "Triforce": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/68/ALttP_Triforce_Title_Sprite.png", + "Zeldas Lullaby": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", + "Eponas Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", + "Sarias Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", + "Suns Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", + "Song of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", + "Song of Storms": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png", + "Minuet of Forest": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e4/Green_Note.png", + "Bolero of Fire": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f0/Red_Note.png", + "Serenade of Water": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0f/Blue_Note.png", + "Requiem of Spirit": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/Orange_Note.png", + "Nocturne of Shadow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/Purple_Note.png", + "Prelude of Light": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/90/Yellow_Note.png", + "Small Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e5/OoT_Small_Key_Icon.png", + "Boss Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/40/OoT_Boss_Key_Icon.png", + } + + display_data = {} + + # Determine display for progressive items + progressive_items = { + "Progressive Hookshot": 66128, + "Progressive Strength Upgrade": 66129, + "Progressive Wallet": 66133, + "Progressive Scale": 66134, + "Magic Meter": 66138, + "Ocarina": 66139, + } + + progressive_names = { + "Progressive Hookshot": ["Hookshot", "Hookshot", "Longshot"], + "Progressive Strength Upgrade": ["Goron Bracelet", "Goron Bracelet", "Silver Gauntlets", + "Golden Gauntlets"], + "Progressive Wallet": ["Adults Wallet", "Adults Wallet", "Giants Wallet", "Giants Wallet"], + "Progressive Scale": ["Silver Scale", "Silver Scale", "Gold Scale"], + "Magic Meter": ["Small Magic", "Small Magic", "Large Magic"], + "Ocarina": ["Fairy Ocarina", "Fairy Ocarina", "Ocarina of Time"] + } + + inventory = tracker_data.get_player_inventory_counts(team, player) + 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] + if item_name.startswith("Progressive"): + base_name = item_name.split(maxsplit=1)[1].lower().replace(" ", "_") + else: + base_name = item_name.lower().replace(" ", "_") + display_data[base_name + "_url"] = icons[display_name] + + if base_name == "hookshot": + display_data["hookshot_length"] = {0: "", 1: "H", 2: "L"}.get(level) + if base_name == "wallet": + display_data["wallet_size"] = {0: "99", 1: "200", 2: "500", 3: "999"}.get(level) + + # Determine display for bottles. Show letter if it's obtained, determine bottle count + bottle_ids = [66015, 66020, 66021, 66140, 66141, 66142, 66143, 66144, 66145, 66146, 66147, 66148] + display_data["bottle_count"] = min(sum(map(lambda item_id: inventory[item_id], bottle_ids)), 4) + display_data["bottle_url"] = icons["Rutos Letter"] if inventory[66021] > 0 else icons["Bottle"] + + # Determine bombchu display + display_data["has_bombchus"] = any(map(lambda item_id: inventory[item_id] > 0, [66003, 66106, 66107, 66137])) + + # Multi-items + multi_items = { + "Gold Skulltula Token": 66091, + "Triforce Piece": 66202, + } + for item_name, item_id in multi_items.items(): + base_name = item_name.split()[-1].lower() + display_data[base_name + "_count"] = inventory[item_id] + + # Gather dungeon locations + area_id_ranges = { + "Overworld": ((67000, 67263), (67269, 67280), (67747, 68024), (68054, 68062)), + "Deku Tree": ((67281, 67303), (68063, 68077)), + "Dodongo's Cavern": ((67304, 67334), (68078, 68160)), + "Jabu Jabu's Belly": ((67335, 67359), (68161, 68188)), + "Bottom of the Well": ((67360, 67384), (68189, 68230)), + "Forest Temple": ((67385, 67420), (68231, 68281)), + "Fire Temple": ((67421, 67457), (68282, 68350)), + "Water Temple": ((67458, 67484), (68351, 68483)), + "Shadow Temple": ((67485, 67532), (68484, 68565)), + "Spirit Temple": ((67533, 67582), (68566, 68625)), + "Ice Cavern": ((67583, 67596), (68626, 68649)), + "Gerudo Training Ground": ((67597, 67635), (68650, 68656)), + "Thieves' Hideout": ((67264, 67268), (68025, 68053)), + "Ganon's Castle": ((67636, 67673), (68657, 68705)), + } + + def lookup_and_trim(id, area): + full_name = tracker_data.location_id_to_name["Ocarina of Time"][id] + if "Ganons Tower" in full_name: + return full_name + if area not in ["Overworld", "Thieves' Hideout"]: + # trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC + return full_name[len(area):] + return full_name + + locations = tracker_data.get_player_locations(team, player) + checked_locations = tracker_data.get_player_checked_locations(team, player).intersection(set(locations)) + location_info = {} + checks_done = {} + checks_in_area = {} + for area, ranges in area_id_ranges.items(): + location_info[area] = {} + checks_done[area] = 0 + checks_in_area[area] = 0 + for r in ranges: + min_id, max_id = r + for id in range(min_id, max_id + 1): + if id in locations: + checked = id in checked_locations + location_info[area][lookup_and_trim(id, area)] = checked + checks_in_area[area] += 1 + checks_done[area] += checked + + checks_done["Total"] = sum(checks_done.values()) + checks_in_area["Total"] = sum(checks_in_area.values()) + + # Give skulltulas on non-tracked locations + non_tracked_locations = tracker_data.get_player_checked_locations(team, player).difference(set(locations)) + for id in non_tracked_locations: + if "GS" in lookup_and_trim(id, ""): + display_data["token_count"] += 1 + + oot_y = "✔" + oot_x = "✕" + + # Gather small and boss key info + small_key_counts = { + "Forest Temple": oot_y if inventory[66203] else inventory[66175], + "Fire Temple": oot_y if inventory[66204] else inventory[66176], + "Water Temple": oot_y if inventory[66205] else inventory[66177], + "Spirit Temple": oot_y if inventory[66206] else inventory[66178], + "Shadow Temple": oot_y if inventory[66207] else inventory[66179], + "Bottom of the Well": oot_y if inventory[66208] else inventory[66180], + "Gerudo Training Ground": oot_y if inventory[66209] else inventory[66181], + "Thieves' Hideout": oot_y if inventory[66210] else inventory[66182], + "Ganon's Castle": oot_y if inventory[66211] else inventory[66183], + } + boss_key_counts = { + "Forest Temple": oot_y if inventory[66149] else oot_x, + "Fire Temple": oot_y if inventory[66150] else oot_x, + "Water Temple": oot_y if inventory[66151] else oot_x, + "Spirit Temple": oot_y if inventory[66152] else oot_x, + "Shadow Temple": oot_y if inventory[66153] else oot_x, + "Ganon's Castle": oot_y if inventory[66154] else oot_x, + } + + # Victory condition + game_state = tracker_data.get_player_client_status(team, player) + display_data["game_finished"] = game_state == 30 + + lookup_any_item_id_to_name = tracker_data.item_id_to_name["Ocarina of Time"] + return render_template( + "tracker__OcarinaOfTime.html", + inventory=inventory, + player=player, + team=team, + room=tracker_data.room, + player_name=tracker_data.get_player_name(team, player), + icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0}, + checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, + small_key_counts=small_key_counts, + boss_key_counts=boss_key_counts, + **display_data, + ) + + _player_trackers["Ocarina of Time"] = render_OcarinaOfTime_tracker + +if "Timespinner" in network_data_package["games"]: + def render_Timespinner_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + icons = { + "Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png", + "Timespinner Spindle": "https://timespinnerwiki.com/mediawiki/images/1/1a/Timespinner_Spindle.png", + "Timespinner Gear 1": "https://timespinnerwiki.com/mediawiki/images/3/3c/Timespinner_Gear_1.png", + "Timespinner Gear 2": "https://timespinnerwiki.com/mediawiki/images/e/e9/Timespinner_Gear_2.png", + "Timespinner Gear 3": "https://timespinnerwiki.com/mediawiki/images/2/22/Timespinner_Gear_3.png", + "Talaria Attachment": "https://timespinnerwiki.com/mediawiki/images/6/61/Talaria_Attachment.png", + "Succubus Hairpin": "https://timespinnerwiki.com/mediawiki/images/4/49/Succubus_Hairpin.png", + "Lightwall": "https://timespinnerwiki.com/mediawiki/images/0/03/Lightwall.png", + "Celestial Sash": "https://timespinnerwiki.com/mediawiki/images/f/f1/Celestial_Sash.png", + "Twin Pyramid Key": "https://timespinnerwiki.com/mediawiki/images/4/49/Twin_Pyramid_Key.png", + "Security Keycard D": "https://timespinnerwiki.com/mediawiki/images/1/1b/Security_Keycard_D.png", + "Security Keycard C": "https://timespinnerwiki.com/mediawiki/images/e/e5/Security_Keycard_C.png", + "Security Keycard B": "https://timespinnerwiki.com/mediawiki/images/f/f6/Security_Keycard_B.png", + "Security Keycard A": "https://timespinnerwiki.com/mediawiki/images/b/b9/Security_Keycard_A.png", + "Library Keycard V": "https://timespinnerwiki.com/mediawiki/images/5/50/Library_Keycard_V.png", + "Tablet": "https://timespinnerwiki.com/mediawiki/images/a/a0/Tablet.png", + "Elevator Keycard": "https://timespinnerwiki.com/mediawiki/images/5/55/Elevator_Keycard.png", + "Oculus Ring": "https://timespinnerwiki.com/mediawiki/images/8/8d/Oculus_Ring.png", + "Water Mask": "https://timespinnerwiki.com/mediawiki/images/0/04/Water_Mask.png", + "Gas Mask": "https://timespinnerwiki.com/mediawiki/images/2/2e/Gas_Mask.png", + "Djinn Inferno": "https://timespinnerwiki.com/mediawiki/images/f/f6/Djinn_Inferno.png", + "Pyro Ring": "https://timespinnerwiki.com/mediawiki/images/2/2c/Pyro_Ring.png", + "Infernal Flames": "https://timespinnerwiki.com/mediawiki/images/1/1f/Infernal_Flames.png", + "Fire Orb": "https://timespinnerwiki.com/mediawiki/images/3/3e/Fire_Orb.png", + "Royal Ring": "https://timespinnerwiki.com/mediawiki/images/f/f3/Royal_Ring.png", + "Plasma Geyser": "https://timespinnerwiki.com/mediawiki/images/1/12/Plasma_Geyser.png", + "Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png", + "Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png", + "Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png", + } + + timespinner_location_ids = { + "Present": [ + 1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009, + 1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019, + 1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029, + 1337030, 1337031, 1337032, 1337033, 1337034, 1337035, 1337036, 1337037, 1337038, 1337039, + 1337040, 1337041, 1337042, 1337043, 1337044, 1337045, 1337046, 1337047, 1337048, 1337049, + 1337050, 1337051, 1337052, 1337053, 1337054, 1337055, 1337056, 1337057, 1337058, 1337059, + 1337060, 1337061, 1337062, 1337063, 1337064, 1337065, 1337066, 1337067, 1337068, 1337069, + 1337070, 1337071, 1337072, 1337073, 1337074, 1337075, 1337076, 1337077, 1337078, 1337079, + 1337080, 1337081, 1337082, 1337083, 1337084, 1337085], + "Past": [ + 1337086, 1337087, 1337088, 1337089, + 1337090, 1337091, 1337092, 1337093, 1337094, 1337095, 1337096, 1337097, 1337098, 1337099, + 1337100, 1337101, 1337102, 1337103, 1337104, 1337105, 1337106, 1337107, 1337108, 1337109, + 1337110, 1337111, 1337112, 1337113, 1337114, 1337115, 1337116, 1337117, 1337118, 1337119, + 1337120, 1337121, 1337122, 1337123, 1337124, 1337125, 1337126, 1337127, 1337128, 1337129, + 1337130, 1337131, 1337132, 1337133, 1337134, 1337135, 1337136, 1337137, 1337138, 1337139, + 1337140, 1337141, 1337142, 1337143, 1337144, 1337145, 1337146, 1337147, 1337148, 1337149, + 1337150, 1337151, 1337152, 1337153, 1337154, 1337155, + 1337171, 1337172, 1337173, 1337174, 1337175], + "Ancient Pyramid": [ + 1337236, + 1337246, 1337247, 1337248, 1337249] + } + + slot_data = tracker_data.get_slot_data(team, player) + if (slot_data["DownloadableItems"]): + timespinner_location_ids["Present"] += [ + 1337156, 1337157, 1337159, + 1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169, + 1337170] + if (slot_data["Cantoran"]): + timespinner_location_ids["Past"].append(1337176) + if (slot_data["LoreChecks"]): + timespinner_location_ids["Present"] += [ + 1337177, 1337178, 1337179, + 1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187] + timespinner_location_ids["Past"] += [ + 1337188, 1337189, + 1337190, 1337191, 1337192, 1337193, 1337194, 1337195, 1337196, 1337197, 1337198] + if (slot_data["GyreArchives"]): + timespinner_location_ids["Ancient Pyramid"] += [ + 1337237, 1337238, 1337239, + 1337240, 1337241, 1337242, 1337243, 1337244, 1337245] + + display_data = {} + + # Victory condition + game_state = tracker_data.get_player_client_status(team, player) + display_data["game_finished"] = game_state == 30 + + inventory = tracker_data.get_player_inventory_counts(team, player) + + # Turn location IDs into advancement tab counts + checked_locations = tracker_data.get_player_checked_locations(team, player) + lookup_name = lambda id: tracker_data.location_id_to_name["Timespinner"][id] + location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations} + for tab_name, tab_locations in timespinner_location_ids.items()} + checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations]) + for tab_name, tab_locations in timespinner_location_ids.items()} + checks_done["Total"] = len(checked_locations) + checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in timespinner_location_ids.items()} + checks_in_area["Total"] = sum(checks_in_area.values()) + options = {k for k, v in slot_data.items() if v} + + lookup_any_item_id_to_name = tracker_data.item_id_to_name["Timespinner"] + return render_template( + "tracker__Timespinner.html", + inventory=inventory, + icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0}, + player=player, + team=team, + room=tracker_data.room, + player_name=tracker_data.get_player_name(team, player), + checks_done=checks_done, + checks_in_area=checks_in_area, + location_info=location_info, + options=options, + **display_data, + ) + + _player_trackers["Timespinner"] = render_Timespinner_tracker + +if "Super Metroid" in network_data_package["games"]: + def render_SuperMetroid_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + icons = { + "Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png", + "Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png", + "Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png", + "Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png", + "Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png", + "Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png", + "Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png", + "Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png", + "Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png", + "Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png", + "Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png", + "Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png", + "Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png", + "Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png", + "Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png", + "Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png", + "Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png", + "Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png", + "X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png", + "Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png", + "Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png", + "Nothing": "", + "No Energy": "", + "Kraid": "", + "Phantoon": "", + "Draygon": "", + "Ridley": "", + "Mother Brain": "", + } + + multi_items = { + "Energy Tank": 83000, + "Missile": 83001, + "Super Missile": 83002, + "Power Bomb": 83003, + "Reserve Tank": 83020, + } + + supermetroid_location_ids = { + 'Crateria/Blue Brinstar': [82005, 82007, 82008, 82026, 82029, + 82000, 82004, 82006, 82009, 82010, + 82011, 82012, 82027, 82028, 82034, + 82036, 82037], + 'Green/Pink Brinstar': [82017, 82023, 82030, 82033, 82035, + 82013, 82014, 82015, 82016, 82018, + 82019, 82021, 82022, 82024, 82025, + 82031], + 'Red Brinstar': [82038, 82042, 82039, 82040, 82041], + 'Kraid': [82043, 82048, 82044], + 'Norfair': [82050, 82053, 82061, 82066, 82068, + 82049, 82051, 82054, 82055, 82056, + 82062, 82063, 82064, 82065, 82067], + 'Lower Norfair': [82078, 82079, 82080, 82070, 82071, + 82073, 82074, 82075, 82076, 82077], + 'Crocomire': [82052, 82060, 82057, 82058, 82059], + 'Wrecked Ship': [82129, 82132, 82134, 82135, 82001, + 82002, 82003, 82128, 82130, 82131, + 82133], + 'West Maridia': [82138, 82136, 82137, 82139, 82140, + 82141, 82142], + 'East Maridia': [82143, 82145, 82150, 82152, 82154, + 82144, 82146, 82147, 82148, 82149, + 82151], + } + + display_data = {} + inventory = tracker_data.get_player_inventory_counts(team, player) + + for item_name, item_id in multi_items.items(): + base_name = item_name.split()[0].lower() + display_data[base_name + "_count"] = inventory[item_id] + + # Victory condition + game_state = tracker_data.get_player_client_status(team, player) + display_data["game_finished"] = game_state == 30 + + # Turn location IDs into advancement tab counts + checked_locations = tracker_data.get_player_checked_locations(team, player) + lookup_name = lambda id: tracker_data.location_id_to_name["Super Metroid"][id] + location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations} + for tab_name, tab_locations in supermetroid_location_ids.items()} + checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations]) + for tab_name, tab_locations in supermetroid_location_ids.items()} + checks_done['Total'] = len(checked_locations) + checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()} + checks_in_area['Total'] = sum(checks_in_area.values()) + + lookup_any_item_id_to_name = tracker_data.item_id_to_name["Super Metroid"] + return render_template( + "tracker__SuperMetroid.html", + inventory=inventory, + icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0}, + player=player, + team=team, + room=tracker_data.room, + player_name=tracker_data.get_player_name(team, player), + checks_done=checks_done, + checks_in_area=checks_in_area, + location_info=location_info, + **display_data, + ) + + _player_trackers["Super Metroid"] = render_SuperMetroid_tracker + +if "ChecksFinder" in network_data_package["games"]: + def render_ChecksFinder_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + icons = { + "Checks Available": "https://0rganics.org/archipelago/cf/spr_tiles_3.png", + "Map Width": "https://0rganics.org/archipelago/cf/spr_tiles_4.png", + "Map Height": "https://0rganics.org/archipelago/cf/spr_tiles_5.png", + "Map Bombs": "https://0rganics.org/archipelago/cf/spr_tiles_6.png", + + "Nothing": "", + } + + checksfinder_location_ids = { + "Tile 1": 81000, + "Tile 2": 81001, + "Tile 3": 81002, + "Tile 4": 81003, + "Tile 5": 81004, + "Tile 6": 81005, + "Tile 7": 81006, + "Tile 8": 81007, + "Tile 9": 81008, + "Tile 10": 81009, + "Tile 11": 81010, + "Tile 12": 81011, + "Tile 13": 81012, + "Tile 14": 81013, + "Tile 15": 81014, + "Tile 16": 81015, + "Tile 17": 81016, + "Tile 18": 81017, + "Tile 19": 81018, + "Tile 20": 81019, + "Tile 21": 81020, + "Tile 22": 81021, + "Tile 23": 81022, + "Tile 24": 81023, + "Tile 25": 81024, + } + + display_data = {} + inventory = tracker_data.get_player_inventory_counts(team, player) + locations = tracker_data.get_player_locations(team, player) + + # Multi-items + multi_items = { + "Map Width": 80000, + "Map Height": 80001, + "Map Bombs": 80002 + } + for item_name, item_id in multi_items.items(): + base_name = item_name.split()[-1].lower() + count = inventory[item_id] + display_data[base_name + "_count"] = count + display_data[base_name + "_display"] = count + 5 + + # Get location info + checked_locations = tracker_data.get_player_checked_locations(team, player) + lookup_name = lambda id: tracker_data.location_id_to_name["ChecksFinder"][id] + location_info = {tile_name: {lookup_name(tile_location): (tile_location in checked_locations)} for + tile_name, tile_location in checksfinder_location_ids.items() if + tile_location in set(locations)} + checks_done = {tile_name: len([tile_location]) for tile_name, tile_location in checksfinder_location_ids.items() + if tile_location in checked_locations and tile_location in set(locations)} + checks_done['Total'] = len(checked_locations) + checks_in_area = checks_done + + # Calculate checks available + display_data["checks_unlocked"] = min( + display_data["width_count"] + display_data["height_count"] + display_data["bombs_count"] + 5, 25) + display_data["checks_available"] = max(display_data["checks_unlocked"] - len(checked_locations), 0) + + # Victory condition + game_state = tracker_data.get_player_client_status(team, player) + display_data["game_finished"] = game_state == 30 + + lookup_any_item_id_to_name = tracker_data.item_id_to_name["ChecksFinder"] + return render_template( + "tracker__ChecksFinder.html", + inventory=inventory, icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0}, + player=player, + team=team, + room=tracker_data.room, + player_name=tracker_data.get_player_name(team, player), + checks_done=checks_done, + checks_in_area=checks_in_area, + location_info=location_info, + **display_data, + ) + + _player_trackers["ChecksFinder"] = render_ChecksFinder_tracker + +if "Starcraft 2 Wings of Liberty" in network_data_package["games"]: + def render_Starcraft2WingsOfLiberty_tracker(tracker_data: TrackerData, team: int, player: int) -> str: + 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", + "Starting Supply": "https://static.wikia.nocookie.net/starcraft/images/d/d3/TerranSupply_SC2_Icon1.gif", + + "Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png", + "Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png", + "Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png", + "Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png", + "Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png", + "Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png", + "Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png", + "Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png", + "Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png", + "Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png", + "Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png", + "Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png", + "Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png", + "Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png", + "Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png", + "Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png", + "Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png", + "Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png", + + "Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg", + "Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg", + "Sensor Tower": "https://static.wikia.nocookie.net/starcraft/images/d/d2/SensorTower_SC2_Icon1.jpg", + + "Projectile Accelerator (Bunker)": "https://0rganics.org/archipelago/sc2wol/ProjectileAccelerator.png", + "Neosteel Bunker (Bunker)": "https://0rganics.org/archipelago/sc2wol/NeosteelBunker.png", + "Titanium Housing (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/TitaniumHousing.png", + "Hellstorm Batteries (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png", + "Advanced Construction (SCV)": "https://0rganics.org/archipelago/sc2wol/AdvancedConstruction.png", + "Dual-Fusion Welders (SCV)": "https://0rganics.org/archipelago/sc2wol/Dual-FusionWelders.png", + "Fire-Suppression System (Building)": "https://0rganics.org/archipelago/sc2wol/Fire-SuppressionSystem.png", + "Orbital Command (Building)": "https://0rganics.org/archipelago/sc2wol/OrbitalCommandCampaign.png", + + "Marine": "https://static.wikia.nocookie.net/starcraft/images/4/47/Marine_SC2_Icon1.jpg", + "Medic": "https://static.wikia.nocookie.net/starcraft/images/7/74/Medic_SC2_Rend1.jpg", + "Firebat": "https://static.wikia.nocookie.net/starcraft/images/3/3c/Firebat_SC2_Rend1.jpg", + "Marauder": "https://static.wikia.nocookie.net/starcraft/images/b/ba/Marauder_SC2_Icon1.jpg", + "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", + "Goliath": "https://static.wikia.nocookie.net/starcraft/images/e/eb/Goliath_WoL.jpg", + "Diamondback": "https://static.wikia.nocookie.net/starcraft/images/a/a6/Diamondback_WoL.jpg", + "Siege Tank": "https://static.wikia.nocookie.net/starcraft/images/5/57/SiegeTank_SC2_Icon1.jpg", + + "Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png", + "Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.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", + "Viking": "https://static.wikia.nocookie.net/starcraft/images/2/2a/Viking_SC2_Icon1.jpg", + "Banshee": "https://static.wikia.nocookie.net/starcraft/images/3/32/Banshee_SC2_Icon1.jpg", + "Battlecruiser": "https://static.wikia.nocookie.net/starcraft/images/f/f5/Battlecruiser_SC2_Icon1.jpg", + + "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", + "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", + "Hammer Securities": "https://static.wikia.nocookie.net/starcraft/images/3/3b/HammerSecurity_SC2_Icon1.jpg", + "Spartan Company": "https://static.wikia.nocookie.net/starcraft/images/b/be/SpartanCompany_SC2_Icon1.jpg", + "Siege Breakers": "https://static.wikia.nocookie.net/starcraft/images/3/31/SiegeBreakers_SC2_Icon1.jpg", + "Hel's Angel": "https://static.wikia.nocookie.net/starcraft/images/6/63/HelsAngels_SC2_Icon1.jpg", + "Dusk Wings": "https://static.wikia.nocookie.net/starcraft/images/5/52/DuskWings_SC2_Icon1.jpg", + "Jackson's Revenge": "https://static.wikia.nocookie.net/starcraft/images/9/95/JacksonsRevenge_SC2_Icon1.jpg", + + "Ultra-Capacitors": "https://static.wikia.nocookie.net/starcraft/images/2/23/SC2_Lab_Ultra_Capacitors_Icon.png", + "Vanadium Plating": "https://static.wikia.nocookie.net/starcraft/images/6/67/SC2_Lab_VanPlating_Icon.png", + "Orbital Depots": "https://static.wikia.nocookie.net/starcraft/images/0/01/SC2_Lab_Orbital_Depot_Icon.png", + "Micro-Filtering": "https://static.wikia.nocookie.net/starcraft/images/2/20/SC2_Lab_MicroFilter_Icon.png", + "Automated Refinery": "https://static.wikia.nocookie.net/starcraft/images/7/71/SC2_Lab_Auto_Refinery_Icon.png", + "Command Center Reactor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/SC2_Lab_CC_Reactor_Icon.png", + "Raven": "https://static.wikia.nocookie.net/starcraft/images/1/19/SC2_Lab_Raven_Icon.png", + "Science Vessel": "https://static.wikia.nocookie.net/starcraft/images/c/c3/SC2_Lab_SciVes_Icon.png", + "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 (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 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", + + "Zealot": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Icon_Protoss_Zealot.jpg", + "Stalker": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Icon_Protoss_Stalker.jpg", + "High Templar": "https://static.wikia.nocookie.net/starcraft/images/a/a0/Icon_Protoss_High_Templar.jpg", + "Dark Templar": "https://static.wikia.nocookie.net/starcraft/images/9/90/Icon_Protoss_Dark_Templar.jpg", + "Immortal": "https://static.wikia.nocookie.net/starcraft/images/c/c1/Icon_Protoss_Immortal.jpg", + "Colossus": "https://static.wikia.nocookie.net/starcraft/images/4/40/Icon_Protoss_Colossus.jpg", + "Phoenix": "https://static.wikia.nocookie.net/starcraft/images/b/b1/Icon_Protoss_Phoenix.jpg", + "Void Ray": "https://static.wikia.nocookie.net/starcraft/images/1/1d/VoidRay_SC2_Rend1.jpg", + "Carrier": "https://static.wikia.nocookie.net/starcraft/images/2/2c/Icon_Protoss_Carrier.jpg", + + "Nothing": "", + } + sc2wol_location_ids = { + "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, + } + + inventory = tracker_data.get_player_inventory_counts(team, player) + 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, + "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, + "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"], + "Progressive Infantry Armor": ["Infantry Armor Level 1", "Infantry Armor Level 1", + "Infantry Armor Level 2", "Infantry Armor Level 3"], + "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 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(' ', '_') + .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 = { + "+15 Starting Minerals": 800 + SC2WOL_ITEM_ID_OFFSET, + "+15 Starting Vespene": 801 + SC2WOL_ITEM_ID_OFFSET, + "+2 Starting Supply": 802 + SC2WOL_ITEM_ID_OFFSET + } + for item_name, item_id in multi_items.items(): + base_name = item_name.split()[-1].lower() + count = inventory[item_id] + if base_name == "supply": + count = count * 2 + display_data[base_name + "_count"] = count + else: + count = count * 15 + display_data[base_name + "_count"] = count + + # Victory condition + game_state = tracker_data.get_player_client_status(team, player) + display_data["game_finished"] = game_state == 30 + + # Turn location IDs into mission objective counts + locations = tracker_data.get_player_locations(team, player) + checked_locations = tracker_data.get_player_checked_locations(team, player) + lookup_name = lambda id: tracker_data.location_id_to_name["Starcraft 2 Wings of Liberty"][id] + location_info = {mission_name: {lookup_name(id): (id in checked_locations) for id in mission_locations if + id in set(locations)} for mission_name, mission_locations in + sc2wol_location_ids.items()} + checks_done = {mission_name: len( + [id for id in mission_locations if id in checked_locations and id in set(locations)]) for + mission_name, mission_locations in sc2wol_location_ids.items()} + checks_done['Total'] = len(checked_locations) + checks_in_area = {mission_name: len([id for id in mission_locations if id in set(locations)]) for + mission_name, mission_locations in sc2wol_location_ids.items()} + checks_in_area['Total'] = sum(checks_in_area.values()) + + lookup_any_item_id_to_name = tracker_data.item_id_to_name["Starcraft 2 Wings of Liberty"] + return render_template( + "tracker__Starcraft2WingsOfLiberty.html", + inventory=inventory, + icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0}, + player=player, + team=team, + room=tracker_data.room, + player_name=tracker_data.get_player_name(team, player), + checks_done=checks_done, + checks_in_area=checks_in_area, + location_info=location_info, + **display_data, + ) + + _player_trackers["Starcraft 2 Wings of Liberty"] = render_Starcraft2WingsOfLiberty_tracker diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 89a839cfa3..af4ed264aa 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -11,17 +11,46 @@ from flask import request, flash, redirect, url_for, session, render_template from markupsafe import Markup from pony.orm import commit, flush, select, rollback from pony.orm.core import TransactionIntegrityError +import schema import MultiServer from NetUtils import SlotType from Utils import VersionException, __version__ +from worlds import GamesPackage from worlds.Files import AutoPatchRegister +from worlds.AutoWorld import data_package_checksum from . import app from .models import Seed, Room, Slot, GameDataPackage -banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb") +banned_extensions = (".sfc", ".z64", ".n64", ".nes", ".smc", ".sms", ".gb", ".gbc", ".gba") +allowed_options_extensions = (".yaml", ".json", ".yml", ".txt", ".zip") +allowed_generation_extensions = (".archipelago", ".zip") + +games_package_schema = schema.Schema({ + "item_name_groups": {str: [str]}, + "item_name_to_id": {str: int}, + "location_name_groups": {str: [str]}, + "location_name_to_id": {str: int}, + schema.Optional("checksum"): str, + schema.Optional("version"): int, +}) + + +def allowed_options(filename: str) -> bool: + return filename.endswith(allowed_options_extensions) + + +def allowed_generation(filename: str) -> bool: + return filename.endswith(allowed_generation_extensions) + + +def banned_file(filename: str) -> bool: + return filename.endswith(banned_extensions) + def process_multidata(compressed_multidata, files={}): + game_data: GamesPackage + decompressed_multidata = MultiServer.Context.decompress(compressed_multidata) slots: typing.Set[Slot] = set() @@ -30,11 +59,19 @@ def process_multidata(compressed_multidata, files={}): game_data_packages: typing.List[GameDataPackage] = [] for game, game_data in decompressed_multidata["datapackage"].items(): if game_data.get("checksum"): + original_checksum = game_data.pop("checksum") + game_data = games_package_schema.validate(game_data) + game_data = {key: value for key, value in sorted(game_data.items())} + game_data["checksum"] = data_package_checksum(game_data) game_data_package = GameDataPackage(checksum=game_data["checksum"], data=pickle.dumps(game_data)) + if original_checksum != game_data["checksum"]: + raise Exception(f"Original checksum {original_checksum} != " + f"calculated checksum {game_data['checksum']} " + f"for game {game}.") decompressed_multidata["datapackage"][game] = { "version": game_data.get("version", 0), - "checksum": game_data["checksum"] + "checksum": game_data["checksum"], } try: commit() # commit game data package @@ -49,20 +86,21 @@ def process_multidata(compressed_multidata, files={}): if slot_info.type == SlotType.group: continue slots.add(Slot(data=files.get(slot, None), - player_name=slot_info.name, - player_id=slot, - game=slot_info.game)) + player_name=slot_info.name, + player_id=slot, + game=slot_info.game)) flush() # commit slots compressed_multidata = compressed_multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9) return slots, compressed_multidata + def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None): if not owner: owner = session["_id"] infolist = zfile.infolist() - if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist): - flash(Markup("Error: Your .zip file only contains .yaml files. " + if all(allowed_options(file.filename) or file.is_dir() for file in infolist): + flash(Markup("Error: Your .zip file only contains options files. " 'Did you mean to generate a game?')) return @@ -73,7 +111,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s # Load files. for file in infolist: handler = AutoPatchRegister.get_handler(file.filename) - if file.filename.endswith(banned_zip_contents): + if banned_file(file.filename): return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ "Your file was deleted." @@ -104,13 +142,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 @@ -128,35 +174,34 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s flash("No multidata was found in the zip file, which is required.") -@app.route('/uploads', methods=['GET', 'POST']) +@app.route("/uploads", methods=["GET", "POST"]) def uploads(): - if request.method == 'POST': - # check if the post request has the file part - if 'file' not in request.files: - flash('No file part') + if request.method == "POST": + # check if the POST request has a file part. + if "file" not in request.files: + flash("No file part in POST request.") else: - file = request.files['file'] - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '': - flash('No selected file') - elif file and allowed_file(file.filename): - if zipfile.is_zipfile(file): - with zipfile.ZipFile(file, 'r') as zfile: + uploaded_file = request.files["file"] + # If the user does not select file, the browser will still submit an empty string without a file name. + if uploaded_file.filename == "": + flash("No selected file.") + elif uploaded_file and allowed_generation(uploaded_file.filename): + if zipfile.is_zipfile(uploaded_file): + with zipfile.ZipFile(uploaded_file, "r") as zfile: try: res = upload_zip_to_db(zfile) except VersionException: flash(f"Could not load multidata. Wrong Version detected.") else: - if type(res) == str: + if res is str: return res elif res: return redirect(url_for("view_seed", seed=res.id)) else: - file.seek(0) # offset from is_zipfile check + uploaded_file.seek(0) # offset from is_zipfile check # noinspection PyBroadException try: - multidata = file.read() + multidata = uploaded_file.read() slots, multidata = process_multidata(multidata) except Exception as e: flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})") @@ -174,7 +219,3 @@ def user_content(): rooms = select(room for room in Room if room.owner == session["_id"]) seeds = select(seed for seed in Seed if seed.owner == session["_id"]) return render_template("userContent.html", rooms=rooms, seeds=seeds) - - -def allowed_file(filename): - return filename.endswith(('.archipelago', ".zip")) diff --git a/Zelda1Client.py b/Zelda1Client.py index db3d3519aa..cd76a0a5ca 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -13,7 +13,6 @@ from typing import List import Utils from Utils import async_start -from worlds import lookup_any_location_id_to_name from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \ get_base_parser @@ -153,7 +152,7 @@ def get_payload(ctx: ZeldaContext): def reconcile_shops(ctx: ZeldaContext): - checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations] + checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations] shops = [location for location in checked_location_names if "Shop" in location] left_slots = [shop for shop in shops if "Left" in shop] middle_slots = [shop for shop in shops if "Middle" in shop] @@ -191,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone= locations_checked = [] location = None for location in ctx.missing_locations: - location_name = lookup_any_location_id_to_name[location] + location_name = ctx.location_names[location] if location_name in Locations.overworld_locations and zone == "overworld": status = locations_array[Locations.major_location_offsets[location_name]] diff --git a/ZillionClient.py b/ZillionClient.py index 7d32a72261..ef96edab04 100644 --- a/ZillionClient.py +++ b/ZillionClient.py @@ -1,505 +1,10 @@ -import asyncio -import base64 -import platform -from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast +import ModuleUpdate +ModuleUpdate.update() -# CommonClient import first to trigger ModuleUpdater -from CommonClient import CommonContext, server_loop, gui_enabled, \ - ClientCommandProcessor, logger, get_base_parser -from NetUtils import ClientStatus -import Utils -from Utils import async_start - -import colorama # type: ignore - -from zilliandomizer.zri.memory import Memory -from zilliandomizer.zri import events -from zilliandomizer.utils.loc_name_maps import id_to_loc -from zilliandomizer.options import Chars -from zilliandomizer.patch import RescueInfo - -from worlds.zillion.id_maps import make_id_to_others -from worlds.zillion.config import base_id, zillion_map - - -class ZillionCommandProcessor(ClientCommandProcessor): - ctx: "ZillionContext" - - def _cmd_sms(self) -> None: - """ Tell the client that Zillion is running in RetroArch. """ - logger.info("ready to look for game") - self.ctx.look_for_retroarch.set() - - def _cmd_map(self) -> None: - """ Toggle view of the map tracker. """ - self.ctx.ui_toggle_map() - - -class ToggleCallback(Protocol): - def __call__(self) -> None: ... - - -class SetRoomCallback(Protocol): - def __call__(self, rooms: List[List[int]]) -> None: ... - - -class ZillionContext(CommonContext): - game = "Zillion" - command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor - items_handling = 1 # receive items from other players - - known_name: Optional[str] - """ This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """ - - from_game: "asyncio.Queue[events.EventFromGame]" - to_game: "asyncio.Queue[events.EventToGame]" - ap_local_count: int - """ local checks watched by server """ - next_item: int - """ index in `items_received` """ - ap_id_to_name: Dict[int, str] - ap_id_to_zz_id: Dict[int, int] - start_char: Chars = "JJ" - rescues: Dict[int, RescueInfo] = {} - loc_mem_to_id: Dict[int, int] = {} - got_room_info: asyncio.Event - """ flag for connected to server """ - got_slot_data: asyncio.Event - """ serves as a flag for whether I am logged in to the server """ - - look_for_retroarch: asyncio.Event - """ - There is a bug in Python in Windows - https://github.com/python/cpython/issues/91227 - that makes it so if I look for RetroArch before it's ready, - it breaks the asyncio udp transport system. - - As a workaround, we don't look for RetroArch until this event is set. - """ - - ui_toggle_map: ToggleCallback - ui_set_rooms: SetRoomCallback - """ parameter is y 16 x 8 numbers to show in each room """ - - def __init__(self, - server_address: str, - password: str) -> None: - super().__init__(server_address, password) - self.known_name = None - self.from_game = asyncio.Queue() - self.to_game = asyncio.Queue() - self.got_room_info = asyncio.Event() - self.got_slot_data = asyncio.Event() - self.ui_toggle_map = lambda: None - self.ui_set_rooms = lambda rooms: None - - self.look_for_retroarch = asyncio.Event() - if platform.system() != "Windows": - # asyncio udp bug is only on Windows - self.look_for_retroarch.set() - - self.reset_game_state() - - def reset_game_state(self) -> None: - for _ in range(self.from_game.qsize()): - self.from_game.get_nowait() - for _ in range(self.to_game.qsize()): - self.to_game.get_nowait() - self.got_slot_data.clear() - - self.ap_local_count = 0 - self.next_item = 0 - self.ap_id_to_name = {} - self.ap_id_to_zz_id = {} - self.rescues = {} - self.loc_mem_to_id = {} - - self.locations_checked.clear() - self.missing_locations.clear() - self.checked_locations.clear() - self.finished_game = False - self.items_received.clear() - - # override - def on_deathlink(self, data: Dict[str, Any]) -> None: - self.to_game.put_nowait(events.DeathEventToGame()) - return super().on_deathlink(data) - - # override - async def server_auth(self, password_requested: bool = False) -> None: - if password_requested and not self.password: - await super().server_auth(password_requested) - if not self.auth: - logger.info('waiting for connection to game...') - return - logger.info("logging in to server...") - await self.send_connect() - - # override - def run_gui(self) -> None: - from kvui import GameManager - from kivy.core.text import Label as CoreLabel - from kivy.graphics import Ellipse, Color, Rectangle - from kivy.uix.layout import Layout - from kivy.uix.widget import Widget - - class ZillionManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago") - ] - base_title = "Archipelago Zillion Client" - - class MapPanel(Widget): - MAP_WIDTH: ClassVar[int] = 281 - - _number_textures: List[Any] = [] - rooms: List[List[int]] = [] - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - - self.rooms = [[0 for _ in range(8)] for _ in range(16)] - - self._make_numbers() - self.update_map() - - self.bind(pos=self.update_map) - # self.bind(size=self.update_bg) - - def _make_numbers(self) -> None: - self._number_textures = [] - for n in range(10): - label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1)) - label.refresh() - self._number_textures.append(label.texture) - - def update_map(self, *args: Any) -> None: - self.canvas.clear() - - with self.canvas: - Color(1, 1, 1, 1) - Rectangle(source=zillion_map, - pos=self.pos, - size=(ZillionManager.MapPanel.MAP_WIDTH, - int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image - for y in range(16): - for x in range(8): - num = self.rooms[15 - y][x] - if num > 0: - Color(0, 0, 0, 0.4) - pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24] - Ellipse(size=[22, 22], pos=pos) - Color(1, 1, 1, 1) - pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24] - num_texture = self._number_textures[num] - Rectangle(texture=num_texture, size=num_texture.size, pos=pos) - - def build(self) -> Layout: - container = super().build() - self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0) - self.main_area_container.add_widget(self.map_widget) - return container - - def toggle_map_width(self) -> None: - if self.map_widget.width == 0: - self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH - else: - self.map_widget.width = 0 - self.container.do_layout() - - def set_rooms(self, rooms: List[List[int]]) -> None: - self.map_widget.rooms = rooms - self.map_widget.update_map() - - self.ui = ZillionManager(self) - self.ui_toggle_map = lambda: self.ui.toggle_map_width() - self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms) - run_co: Coroutine[Any, Any, None] = self.ui.async_run() - self.ui_task = asyncio.create_task(run_co, name="UI") - - def on_package(self, cmd: str, args: Dict[str, Any]) -> None: - self.room_item_numbers_to_ui() - if cmd == "Connected": - logger.info("logged in to Archipelago server") - if "slot_data" not in args: - logger.warn("`Connected` packet missing `slot_data`") - return - slot_data = args["slot_data"] - - if "start_char" not in slot_data: - logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `start_char`") - return - self.start_char = slot_data['start_char'] - if self.start_char not in {"Apple", "Champ", "JJ"}: - logger.warn("invalid Zillion `Connected` packet, " - f"`slot_data` `start_char` has invalid value: {self.start_char}") - - if "rescues" not in slot_data: - logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `rescues`") - return - rescues = slot_data["rescues"] - self.rescues = {} - for rescue_id, json_info in rescues.items(): - assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}" - # TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch? - assert json_info["start_char"] == self.start_char, \ - f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}' - ri = RescueInfo(json_info["start_char"], - json_info["room_code"], - json_info["mask"]) - self.rescues[0 if rescue_id == "0" else 1] = ri - - if "loc_mem_to_id" not in slot_data: - logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`") - return - loc_mem_to_id = slot_data["loc_mem_to_id"] - self.loc_mem_to_id = {} - for mem_str, id_str in loc_mem_to_id.items(): - mem = int(mem_str) - id_ = int(id_str) - room_i = mem // 256 - assert 0 <= room_i < 74 - assert id_ in id_to_loc - self.loc_mem_to_id[mem] = id_ - - if len(self.loc_mem_to_id) != 394: - logger.warn("invalid Zillion `Connected` packet, " - f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}") - - self.got_slot_data.set() - - payload = { - "cmd": "Get", - "keys": [f"zillion-{self.auth}-doors"] - } - async_start(self.send_msgs([payload])) - elif cmd == "Retrieved": - if "keys" not in args: - logger.warning(f"invalid Retrieved packet to ZillionClient: {args}") - return - keys = cast(Dict[str, Optional[str]], args["keys"]) - doors_b64 = keys[f"zillion-{self.auth}-doors"] - if doors_b64: - logger.info("received door data from server") - doors = base64.b64decode(doors_b64) - self.to_game.put_nowait(events.DoorEventToGame(doors)) - elif cmd == "RoomInfo": - self.seed_name = args["seed_name"] - self.got_room_info.set() - - def room_item_numbers_to_ui(self) -> None: - rooms = [[0 for _ in range(8)] for _ in range(16)] - for loc_id in self.missing_locations: - loc_id_small = loc_id - base_id - loc_name = id_to_loc[loc_id_small] - y = ord(loc_name[0]) - 65 - x = ord(loc_name[2]) - 49 - if y == 9 and x == 5: - # don't show main computer in numbers - continue - assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}" - rooms[y][x] += 1 - # TODO: also add locations with locals lost from loading save state or reset - self.ui_set_rooms(rooms) - - def process_from_game_queue(self) -> None: - if self.from_game.qsize(): - event_from_game = self.from_game.get_nowait() - if isinstance(event_from_game, events.AcquireLocationEventFromGame): - server_id = event_from_game.id + base_id - loc_name = id_to_loc[event_from_game.id] - self.locations_checked.add(server_id) - if server_id in self.missing_locations: - self.ap_local_count += 1 - n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win - logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})') - async_start(self.send_msgs([ - {"cmd": 'LocationChecks', "locations": [server_id]} - ])) - else: - # This will happen a lot in Zillion, - # because all the key words are local and unwatched by the server. - logger.debug(f"DEBUG: {loc_name} not in missing") - elif isinstance(event_from_game, events.DeathEventFromGame): - async_start(self.send_death()) - elif isinstance(event_from_game, events.WinEventFromGame): - if not self.finished_game: - async_start(self.send_msgs([ - {"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL} - ])) - self.finished_game = True - elif isinstance(event_from_game, events.DoorEventFromGame): - if self.auth: - doors_b64 = base64.b64encode(event_from_game.doors).decode() - payload = { - "cmd": "Set", - "key": f"zillion-{self.auth}-doors", - "operations": [{"operation": "replace", "value": doors_b64}] - } - async_start(self.send_msgs([payload])) - else: - logger.warning(f"WARNING: unhandled event from game {event_from_game}") - - def process_items_received(self) -> None: - if len(self.items_received) > self.next_item: - zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received] - for index in range(self.next_item, len(self.items_received)): - ap_id = self.items_received[index].item - from_name = self.player_names[self.items_received[index].player] - # TODO: colors in this text, like sni client? - logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}') - self.to_game.put_nowait( - events.ItemEventToGame(zz_item_ids) - ) - self.next_item = len(self.items_received) - - -def name_seed_from_ram(data: bytes) -> Tuple[str, str]: - """ returns player name, and end of seed string """ - if len(data) == 0: - # no connection to game - return "", "xxx" - null_index = data.find(b'\x00') - if null_index == -1: - logger.warning(f"invalid game id in rom {repr(data)}") - null_index = len(data) - name = data[:null_index].decode() - null_index_2 = data.find(b'\x00', null_index + 1) - if null_index_2 == -1: - null_index_2 = len(data) - seed_name = data[null_index + 1:null_index_2].decode() - - return name, seed_name - - -async def zillion_sync_task(ctx: ZillionContext) -> None: - logger.info("started zillion sync task") - - # to work around the Python bug where we can't check for RetroArch - if not ctx.look_for_retroarch.is_set(): - logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.") - await asyncio.wait(( - asyncio.create_task(ctx.look_for_retroarch.wait()), - asyncio.create_task(ctx.exit_event.wait()) - ), return_when=asyncio.FIRST_COMPLETED) - - last_log = "" - - def log_no_spam(msg: str) -> None: - nonlocal last_log - if msg != last_log: - last_log = msg - logger.info(msg) - - # to only show this message once per client run - help_message_shown = False - - with Memory(ctx.from_game, ctx.to_game) as memory: - while not ctx.exit_event.is_set(): - ram = await memory.read() - game_id = memory.get_rom_to_ram_data(ram) - name, seed_end = name_seed_from_ram(game_id) - if len(name): - if name == ctx.known_name: - ctx.auth = name - # this is the name we know - if ctx.server and ctx.server.socket: # type: ignore - if ctx.got_room_info.is_set(): - if ctx.seed_name and ctx.seed_name.endswith(seed_end): - # correct seed - if memory.have_generation_info(): - log_no_spam("everything connected") - await memory.process_ram(ram) - ctx.process_from_game_queue() - ctx.process_items_received() - else: # no generation info - if ctx.got_slot_data.is_set(): - memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id) - ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \ - make_id_to_others(ctx.start_char) - ctx.next_item = 0 - ctx.ap_local_count = len(ctx.checked_locations) - else: # no slot data yet - async_start(ctx.send_connect()) - log_no_spam("logging in to server...") - await asyncio.wait(( - asyncio.create_task(ctx.got_slot_data.wait()), - asyncio.create_task(ctx.exit_event.wait()), - asyncio.create_task(asyncio.sleep(6)) - ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets - else: # not correct seed name - log_no_spam("incorrect seed - did you mix up roms?") - else: # no room info - # If we get here, it looks like `RoomInfo` packet got lost - log_no_spam("waiting for room info from server...") - else: # server not connected - log_no_spam("waiting for server connection...") - else: # new game - log_no_spam("connected to new game") - await ctx.disconnect() - ctx.reset_server_state() - ctx.seed_name = None - ctx.got_room_info.clear() - ctx.reset_game_state() - memory.reset_game_state() - - ctx.auth = name - ctx.known_name = name - async_start(ctx.connect()) - await asyncio.wait(( - asyncio.create_task(ctx.got_room_info.wait()), - asyncio.create_task(ctx.exit_event.wait()), - asyncio.create_task(asyncio.sleep(6)) - ), return_when=asyncio.FIRST_COMPLETED) - else: # no name found in game - if not help_message_shown: - logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.') - help_message_shown = True - log_no_spam("looking for connection to game...") - await asyncio.sleep(0.3) - - await asyncio.sleep(0.09375) - logger.info("zillion sync task ending") - - -async def main() -> None: - parser = get_base_parser() - parser.add_argument('diff_file', default="", type=str, nargs="?", - help='Path to a .apzl Archipelago Binary Patch file') - # SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical']) - args = parser.parse_args() - print(args) - - if args.diff_file: - import Patch - logger.info("patch file was supplied - creating sms rom...") - meta, rom_file = Patch.create_rom_file(args.diff_file) - if "server" in meta: - args.connect = meta["server"] - logger.info(f"wrote rom file to {rom_file}") - - ctx = ZillionContext(args.connect, args.password) - 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() - - sync_task = asyncio.create_task(zillion_sync_task(ctx)) - - await ctx.exit_event.wait() - - ctx.server_address = None - logger.debug("waiting for sync task to end") - await sync_task - logger.debug("sync task ended") - await ctx.shutdown() +import Utils # noqa: E402 +from worlds.zillion.client import launch # noqa: E402 if __name__ == "__main__": Utils.init_logging("ZillionClient", exception_logger="Client") - - colorama.init() - asyncio.run(main()) - colorama.deinit() + launch() diff --git a/data/client.kv b/data/client.kv index f0e3616900..3b48d216dd 100644 --- a/data/client.kv +++ b/data/client.kv @@ -17,6 +17,12 @@ color: "FFFFFF" : tab_width: root.width / app.tab_count +: + text_size: self.width, None + size_hint_y: None + height: self.texture_size[1] + font_size: dp(20) + markup: True : canvas.before: Color: @@ -24,11 +30,6 @@ Rectangle: size: self.size pos: self.pos - text_size: self.width, None - size_hint_y: None - height: self.texture_size[1] - font_size: dp(20) - markup: True : messages: 1000 # amount of messages stored in client logs. cols: 1 @@ -44,6 +45,70 @@ height: self.minimum_height orientation: 'vertical' spacing: dp(3) +: + canvas.before: + Color: + rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1) + Rectangle: + size: self.size + pos: self.pos + height: self.minimum_height + receiving_text: "Receiving Player" + item_text: "Item" + finding_text: "Finding Player" + location_text: "Location" + entrance_text: "Entrance" + found_text: "Found?" + TooltipLabel: + id: receiving + text: root.receiving_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: item + text: root.item_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: finding + text: root.finding_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: location + text: root.location_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: entrance + text: root.entrance_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: found + text: root.found_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} +: + cols: 1 + viewclass: 'HintLabel' + scroll_y: self.height + scroll_type: ["content", "bars"] + bar_width: dp(12) + effect_cls: "ScrollEffect" + SelectableRecycleBoxLayout: + default_size: None, dp(20) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + spacing: dp(3) : text: "Server:" size_hint_x: None diff --git a/data/lua/base64.lua b/data/lua/base64.lua new file mode 100644 index 0000000000..ebe8064353 --- /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 0000000000..47af6e003d --- /dev/null +++ b/data/lua/connector_bizhawk_generic.lua @@ -0,0 +1,598 @@ +--[[ +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 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 + +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 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_FIRST = 43055 +local SOCKET_PORT_RANGE_SIZE = 5 +local SOCKET_PORT_LAST = SOCKET_PORT_FIRST + SOCKET_PORT_RANGE_SIZE + +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 + +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 + 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 + if type(response) ~= "string" then response = "Unknown error" end + res[i] = {type = "ERROR", err = response} + end + end + end + + client_socket:send(json.encode(res).."\n") + end +end + +function initialize_server () + local err + local port = SOCKET_PORT_FIRST + local res = nil + + server, err = socket.socket.tcp4() + while res == nil and port <= SOCKET_PORT_LAST do + res, err = server:bind("localhost", port) + if res == nil and err ~= "address already in use" then + print(err) + return + end + + if res == nil then + port = port + 1 + end + end + + if port > SOCKET_PORT_LAST then + print("Too many instances of connector script already running. Exiting.") + return + end + + res, err = server:listen(0) + + if err ~= nil then + print(err) + return + end + + server:settimeout(0) +end + +function main () + while true do + if server == nil then + initialize_server() + end + + 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() % 30 == 0 then + print("Looking for client...") + local client, timeout = server:accept() + if timeout == nil then + print("Client connected") + current_state = STATE_CONNECTED + client_socket = client + server:close() + server = nil + client_socket:settimeout(0) + 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. This may take longer the more instances of this script you have open at once.\n") + + local co = coroutine.create(main) + function tick () + local status, err = coroutine.resume(co) + + if not status and err ~= "cannot resume dead coroutine" 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" or emu.getsystemid() == "SGB" 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_pkmn_rb.lua b/data/lua/connector_pkmn_rb.lua deleted file mode 100644 index 3f56435bdb..0000000000 --- a/data/lua/connector_pkmn_rb.lua +++ /dev/null @@ -1,224 +0,0 @@ -local socket = require("socket") -local json = require('json') -local math = require('math') -require("common") -local STATE_OK = "Ok" -local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" -local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made" -local STATE_UNINITIALIZED = "Uninitialized" - -local SCRIPT_VERSION = 3 - -local APIndex = 0x1A6E -local APDeathLinkAddress = 0x00FD -local APItemAddress = 0x00FF -local EventFlagAddress = 0x1735 -local MissableAddress = 0x161A -local HiddenItemsAddress = 0x16DE -local RodAddress = 0x1716 -local DexSanityAddress = 0x1A71 -local InGameAddress = 0x1A84 -local ClientCompatibilityAddress = 0xFF00 - -local ItemsReceived = nil -local playerName = nil -local seedName = nil - -local deathlink_rec = nil -local deathlink_send = false - -local prevstate = "" -local curstate = STATE_UNINITIALIZED -local gbSocket = nil -local frame = 0 - -local compat = nil - -local function defineMemoryFunctions() - local memDomain = {} - local domains = memory.getmemorydomainlist() - memDomain["rom"] = function() memory.usememorydomain("ROM") end - memDomain["wram"] = function() memory.usememorydomain("WRAM") end - return memDomain -end - -local memDomain = defineMemoryFunctions() -u8 = memory.read_u8 -wU8 = memory.write_u8 -u16 = memory.read_u16_le -function uRange(address, bytes) - data = memory.readbyterange(address - 1, bytes + 1) - data[0] = nil - return data -end - -function generateLocationsChecked() - memDomain.wram() - events = uRange(EventFlagAddress, 0x140) - missables = uRange(MissableAddress, 0x20) - hiddenitems = uRange(HiddenItemsAddress, 0x0E) - rod = {u8(RodAddress)} - dexsanity = uRange(DexSanityAddress, 19) - - - data = {} - - categories = {events, missables, hiddenitems, rod} - if compat > 1 then - table.insert(categories, dexsanity) - end - for _, category in ipairs(categories) do - for _, v in ipairs(category) do - table.insert(data, v) - end - end - - return data -end - -local function arrayEqual(a1, a2) - if #a1 ~= #a2 then - return false - end - - for i, v in ipairs(a1) do - if v ~= a2[i] then - return false - end - end - - return true -end - -function receive() - l, e = gbSocket:receive() - if e == 'closed' then - if curstate == STATE_OK then - print("Connection closed") - end - curstate = STATE_UNINITIALIZED - return - elseif e == 'timeout' then - return - elseif e ~= nil then - print(e) - curstate = STATE_UNINITIALIZED - return - end - if l ~= nil then - block = json.decode(l) - if block ~= nil then - local itemsBlock = block["items"] - if itemsBlock ~= nil then - ItemsReceived = itemsBlock - end - deathlink_rec = block["deathlink"] - - end - end - -- Determine Message to send back - memDomain.rom() - newPlayerName = uRange(0xFFF0, 0x10) - newSeedName = uRange(0xFFDB, 21) - if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then - print("ROM changed, quitting") - curstate = STATE_UNINITIALIZED - return - end - playerName = newPlayerName - seedName = newSeedName - local retTable = {} - retTable["scriptVersion"] = SCRIPT_VERSION - - if compat == nil then - compat = u8(ClientCompatibilityAddress) - if compat < 2 then - InGameAddress = 0x1A71 - end - end - - retTable["clientCompatibilityVersion"] = compat - retTable["playerName"] = playerName - retTable["seedName"] = seedName - memDomain.wram() - - in_game = u8(InGameAddress) - if in_game == 0x2A or in_game == 0xAC then - retTable["locations"] = generateLocationsChecked() - elseif in_game ~= 0 then - print("Game may have crashed") - curstate = STATE_UNINITIALIZED - return - end - - retTable["deathLink"] = deathlink_send - deathlink_send = false - - msg = json.encode(retTable).."\n" - local ret, error = gbSocket:send(msg) - if ret == nil then - print(error) - elseif curstate == STATE_INITIAL_CONNECTION_MADE then - curstate = STATE_TENTATIVELY_CONNECTED - elseif curstate == STATE_TENTATIVELY_CONNECTED then - print("Connected!") - curstate = STATE_OK - end -end - -function main() - if not checkBizHawkVersion() then - return - end - server, error = socket.bind('localhost', 17242) - - while true do - frame = frame + 1 - if not (curstate == prevstate) then - print("Current state: "..curstate) - prevstate = curstate - end - if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then - if (frame % 5 == 0) then - receive() - in_game = u8(InGameAddress) - if in_game == 0x2A or in_game == 0xAC then - if u8(APItemAddress) == 0x00 then - ItemIndex = u16(APIndex) - if deathlink_rec == true then - wU8(APDeathLinkAddress, 1) - elseif u8(APDeathLinkAddress) == 3 then - wU8(APDeathLinkAddress, 0) - deathlink_send = true - end - if ItemsReceived[ItemIndex + 1] ~= nil then - item_id = ItemsReceived[ItemIndex + 1] - 172000000 - if item_id > 255 then - item_id = item_id - 256 - end - wU8(APItemAddress, item_id) - end - end - end - end - elseif (curstate == STATE_UNINITIALIZED) then - if (frame % 60 == 0) then - - print("Waiting for client.") - - emu.frameadvance() - server:settimeout(2) - print("Attempting to connect") - local client, timeout = server:accept() - if timeout == nil then - curstate = STATE_INITIAL_CONNECTION_MADE - gbSocket = client - gbSocket:settimeout(0) - end - end - end - emu.frameadvance() - end -end - -main() diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index e92bfa42b6..e221371b24 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -46,12 +46,21 @@ # DOOM 1993 /worlds/doom_1993/ @Daivuk +# DOOM II +/worlds/doom_ii/ @Daivuk + # Factorio /worlds/factorio/ @Berserker66 # Final Fantasy /worlds/ff1/ @jtoyoda +# Final Fantasy Mystic Quest +/worlds/ffmq/ @Alchav @wildham0 + +# Heretic +/worlds/heretic/ @Daivuk + # Hollow Knight /worlds/hk/ @BadMagic100 @ThePhar @@ -61,6 +70,12 @@ # Kingdom Hearts 2 /worlds/kh2/ @JaredWeakStrike +# Landstalker: The Treasures of King Nole +/worlds/landstalker/ @Dinopony + +# Lingo +/worlds/lingo/ @hatkirby + # Links Awakening DX /worlds/ladx/ @zig-for @@ -92,6 +107,9 @@ # Overcooked! 2 /worlds/overcooked2/ @toasterparty +# Pokemon Emerald +/worlds/pokemon_emerald/ @Zunawe + # Pokemon Red and Blue /worlds/pokemon_rb/ @Alchav @@ -104,6 +122,9 @@ # Risk of Rain 2 /worlds/ror2/ @kindasneaki +# Shivers +/worlds/shivers/ @GodlFire + # Sonic Adventure 2 Battle /worlds/sa2b/ @PoryGone @RaspberrySpace diff --git a/docs/adding games.md b/docs/adding games.md index 24d9e499cd..e9f7860fc6 100644 --- a/docs/adding games.md +++ b/docs/adding games.md @@ -1,214 +1,206 @@ +# How do I add a game to Archipelago? - -# How do I add a game to Archipelago? This guide is going to try and be a broad summary of how you can do just that. -There are two key steps to incorporating a game into Archipelago: -- Game Modification +There are two key steps to incorporating a game into Archipelago: + +- Game Modification - Archipelago Server Integration Refer to the following documents as well: -- [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) for network communication between client and server. -- [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md) for documentation on server side code and creating a world package. +- [network protocol.md](/docs/network%20protocol.md) for network communication between client and server. +- [world api.md](/docs/world%20api.md) for documentation on server side code and creating a world package. -# Game Modification -One half of the work required to integrate a game into Archipelago is the development of the game client. This is +# Game Modification + +One half of the work required to integrate a game into Archipelago is the development of the game client. This is typically done through a modding API or other modification process, described further down. As an example, modifications to a game typically include (more on this later): + - Hooking into when a 'location check' is completed. - Networking with the Archipelago server. - Optionally, UI or HUD updates to show status of the multiworld session or Archipelago server connection. In order to determine how to modify a game, refer to the following sections. - -## Engine Identification -This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is critical. The first step is to look at a game's files. Let's go over what some game files might look like. It’s important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice. + +## Engine Identification + +This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is +critical. The first step is to look at a game's files. Let's go over what some game files might look like. It’s +important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice. Examples are provided below. - + ### Creepy Castle -![Creepy Castle Root Directory in Window's Explorer](./img/creepy-castle-directory.png) - + +![Creepy Castle Root Directory in Windows Explorer](/docs/img/creepy-castle-directory.png) + This is the delightful title Creepy Castle, which is a fantastic game that I highly recommend. It’s also your worst-case -scenario as a modder. All that’s present here is an executable file and some meta-information that Steam uses. You have -basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty nasty -disassembly and reverse engineering work, which is outside the scope of this tutorial. Let’s look at some other examples -of game releases. +scenario as a modder. All that’s present here is an executable file and some meta-information that Steam uses. You have +basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty +nasty disassembly and reverse engineering work, which is outside the scope of this tutorial. Let’s look at some other +examples of game releases. ### Heavy Bullets -![Heavy Bullets Root Directory in Window's Explorer](./img/heavy-bullets-directory.png) - -Here’s the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files. -“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually -with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing -information, credits, and general info about the game. You usually won’t find anything too helpful here, but it never -hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important. -“steam_api.dll” is a file you can safely ignore, it’s just some code used to interface with Steam. -The directory “HEAVY_BULLETS_Data”, however, has some good news. - -![Heavy Bullets Data Directory in Window's Explorer](./img/heavy-bullets-data-directory.png) - -Jackpot! It might not be obvious what you’re looking at here, but I can instantly tell from this folder’s contents that -what we have is a game made in the Unity Engine. If you look in the sub-folders, you’ll seem some .dll files which affirm -our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered, extension-less -level files and the sharedassets files. We’ll tell you a bit about why seeing a Unity game is such good news later, -but for now, this is what one looks like. Also keep your eyes out for an executable with a name like UnityCrashHandler, -that’s another dead giveaway. + +![Heavy Bullets Root Directory in Window's Explorer](/docs/img/heavy-bullets-directory.png) + +Here’s the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files. +“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually +with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing +information, credits, and general info about the game. You usually won’t find anything too helpful here, but it never +hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important. +“steam_api.dll” is a file you can safely ignore, it’s just some code used to interface with Steam. +The directory “HEAVY_BULLETS_Data”, however, has some good news. + +![Heavy Bullets Data Directory in Window's Explorer](/docs/img/heavy-bullets-data-directory.png) + +Jackpot! It might not be obvious what you’re looking at here, but I can instantly tell from this folder’s contents that +what we have is a game made in the Unity Engine. If you look in the sub-folders, you’ll seem some .dll files which +affirm our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered, +extension-less level files and the sharedassets files. If you've identified the game as a Unity game, some useful tools +and information to help you on your journey can be found at this +[Unity Game Hacking guide.](https://github.com/imadr/Unity-game-hacking) ### Stardew Valley -![Stardew Valley Root Directory in Window's Explorer](./img/stardew-valley-directory.png) - -This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways. -Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good news. -More on that later. + +![Stardew Valley Root Directory in Window's Explorer](/docs/img/stardew-valley-directory.png) + +This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways. +Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good +news. Many games made in C# can be modified using the same tools found in our Unity game hacking toolset; namely BepInEx +and MonoMod. ### Gato Roboto -![Gato Roboto Root Directory in Window's Explorer](./img/gato-roboto-directory.png) - -Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for. -The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker. - -This isn't all you'll ever see looking at game files, but it's a good place to start. -As a general rule, the more files a game has out in plain sight, the more you'll be able to change. -This especially applies in the case of code or script files - always keep a lookout for anything you can use to your -advantage! - + +![Gato Roboto Root Directory in Window's Explorer](/docs/img/gato-roboto-directory.png) + +Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for. +The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker. For +modifying GameMaker games the [Undertale Mod Tool](https://github.com/krzys-h/UndertaleModTool) is incredibly helpful. + +This isn't all you'll ever see looking at game files, but it's a good place to start. +As a general rule, the more files a game has out in plain sight, the more you'll be able to change. +This especially applies in the case of code or script files - always keep a lookout for anything you can use to your +advantage! + ## Open or Leaked Source Games -As a side note, many games have either been made open source, or have had source files leaked at some point. -This can be a boon to any would-be modder, for obvious reasons. -Always be sure to check - a quick internet search for "(Game) Source Code" might not give results often, but when it -does you're going to have a much better time. - + +As a side note, many games have either been made open source, or have had source files leaked at some point. +This can be a boon to any would-be modder, for obvious reasons. Always be sure to check - a quick internet search for +"(Game) Source Code" might not give results often, but when it does, you're going to have a much better time. + Be sure never to distribute source code for games that you decompile or find if you do not have express permission to do so, or to redistribute any materials obtained through similar methods, as this is illegal and unethical. - -## Modifying Release Versions of Games -However, for now we'll assume you haven't been so lucky, and have to work with only what’s sitting in your install directory. -Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools, -but these are often not geared to the kind of work you'll be doing and may not help much. -As a general rule, any modding tool that lets you write actual code is something worth using. - +## Modifying Release Versions of Games + +However, for now we'll assume you haven't been so lucky, and have to work with only what’s sitting in your install +directory. Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools, +but these are often not geared to the kind of work you'll be doing and may not help much. + +As a general rule, any modding tool that lets you write actual code is something worth using. + ### Research -The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification, -it's possible other motivated parties have concocted useful tools for your game already. -Always be sure to search the Internet for the efforts of other modders. - -### Analysis Tools -Depending on the game’s underlying engine, there may be some tools you can use either in lieu of or in addition to existing game tools. - -#### [dnSpy](https://github.com/dnSpy/dnSpy/releases) -The first tool in your toolbox is dnSpy. -dnSpy is useful for opening and modifying code files, like .exe and .dll files, that were made in C#. -This won't work for executable files made by other means, and obfuscated code (code which was deliberately made -difficult to reverse engineer) will thwart it, but 9 times out of 10 this is exactly what you need. -You'll want to avoid opening common library files in dnSpy, as these are unlikely to contain the data you're looking to -modify. -For Unity games, the file you’ll want to open will be the file (Data Folder)/Managed/Assembly-CSharp.dll, as pictured below: - -![Heavy Bullets Managed Directory in Window's Explorer](./img/heavy-bullets-managed-directory.png) - -This file will contain the data of the actual game. -For other C# games, the file you want is usually just the executable itself. - -With dnSpy, you can view the game’s C# code, but the tool isn’t perfect. -Although the names of classes, methods, variables, and more will be preserved, code structures may not remain entirely intact. This is because compilers will often subtly rewrite code to be more optimal, so that it works the same as the original code but uses fewer resources. Compiled C# files also lose comments and other documentation. - -#### [UndertaleModTool](https://github.com/krzys-h/UndertaleModTool/releases) -This is currently the best tool for modifying games made in GameMaker, and supports games made in both GMS 1 and 2. -It allows you to modify code in GML, if the game wasn't made with the wrong compiler (usually something you don't have -to worry about). +The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification, +it's possible other motivated parties have concocted useful tools for your game already. +Always be sure to search the Internet for the efforts of other modders. -You'll want to open the data.win file, as this is where all the goods are kept. -Like dnSpy, you won’t be able to see comments. -In addition, you will be able to see and modify many hidden fields on items that GameMaker itself will often hide from -creators. +### Other helpful tools -Fonts in particular are notoriously complex, and to add new sprites you may need to modify existing sprite sheets. - -#### [CheatEngine](https://cheatengine.org/) -CheatEngine is a tool with a very long and storied history. -Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as -malware (because this behavior is most commonly found in malware and rarely used by other programs). -If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level, -including binary data formats, addressing, and assembly language programming. +Depending on the game’s underlying engine, there may be some tools you can use either in lieu of or in addition to +existing game tools. -The tool itself is highly complex and even I have not yet charted its expanses. +#### [CheatEngine](https://cheatengine.org/) + +CheatEngine is a tool with a very long and storied history. +Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as +malware (because this behavior is most commonly found in malware and rarely used by other programs). +If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level, +including binary data formats, addressing, and assembly language programming. + +The tool itself is highly complex and even I have not yet charted its expanses. However, it can also be a very powerful tool in the right hands, allowing you to query and modify gamestate without ever -modifying the actual game itself. -In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do -anything with it. - -### What Modifications You Should Make to the Game -We talked about this briefly in [Game Modification](#game-modification) section. -The next step is to know what you need to make the game do now that you can modify it. Here are your key goals: -- Modify the game so that checks are shuffled -- Know when the player has completed a check, and react accordingly -- Listen for messages from the Archipelago server -- Modify the game to display messages from the Archipelago server -- Add interface for connecting to the Archipelago server with passwords and sessions -- Add commands for manually rewarding, re-syncing, releasing, and other actions - -To elaborate, you need to be able to inform the server whenever you check locations, print out messages that you receive -from the server in-game so players can read them, award items when the server tells you to, sync and re-sync when necessary, -avoid double-awarding items while still maintaining game file integrity, and allow players to manually enter commands in -case the client or server make mistakes. +modifying the actual game itself. +In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do +anything with it. + +### What Modifications You Should Make to the Game + +We talked about this briefly in [Game Modification](#game-modification) section. +The next step is to know what you need to make the game do now that you can modify it. Here are your key goals: + +- Know when the player has checked a location, and react accordingly +- Be able to receive items from the server on the fly +- Keep an index for items received in order to resync from disconnections +- Add interface for connecting to the Archipelago server with passwords and sessions +- Add commands for manually rewarding, re-syncing, releasing, and other actions + +Refer to the [Network Protocol documentation](/docs/network%20protocol.md) for how to communicate with Archipelago's +servers. + +## But my Game is a console game. Can I still add it? + +That depends – what console? + +### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc -Refer to the [Network Protocol documentation](./network%20protocol.md) for how to communicate with Archipelago's servers. - -## But my Game is a console game. Can I still add it? -That depends – what console? - -### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc Most games for recent generations of console platforms are inaccessible to the typical modder. It is generally advised that you do not attempt to work with these games as they are difficult to modify and are protected by their copyright -holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console games. - -### My Game isn’t that old, it’s for the Wii/PS2/360/etc -This is very complex, but doable. -If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it. +holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console +games. + +### My Game isn’t that old, it’s for the Wii/PS2/360/etc + +This is very complex, but doable. +If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it. There exist many disassembly and debugging tools, but more recent content may have lackluster support. - -### My Game is a classic for the SNES/Sega Genesis/etc -That’s a lot more feasible. -There are many good tools available for understanding and modifying games on these older consoles, and the emulation -community will have figured out the bulk of the console’s secrets. -Look for debugging tools, but be ready to learn assembly. -Old consoles usually have their own unique dialects of ASM you’ll need to get used to. + +### My Game is a classic for the SNES/Sega Genesis/etc + +That’s a lot more feasible. +There are many good tools available for understanding and modifying games on these older consoles, and the emulation +community will have figured out the bulk of the console’s secrets. +Look for debugging tools, but be ready to learn assembly. +Old consoles usually have their own unique dialects of ASM you’ll need to get used to. Also make sure there’s a good way to interface with a running emulator, since that’s the only way you can connect these older consoles to the Internet. -There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a computer, -but these will require the same sort of interface software to be written in order to work properly - from your perspective -the two won't really look any different. - -### My Game is an exclusive for the Super Baby Magic Dream Boy. It’s this console from the Soviet Union that- -Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no. +There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a +computer, but these will require the same sort of interface software to be written in order to work properly; from your +perspective the two won't really look any different. + +### My Game is an exclusive for the Super Baby Magic Dream Boy. It’s this console from the Soviet Union that- + +Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no. Obscurity is your enemy – there will likely be little to no emulator or modding information, and you’d essentially be -working from scratch. - +working from scratch. + ## How to Distribute Game Modifications + **NEVER EVER distribute anyone else's copyrighted work UNLESS THEY EXPLICITLY GIVE YOU PERMISSION TO DO SO!!!** This is a good way to get any project you're working on sued out from under you. The right way to distribute modified versions of a game's binaries, assuming that the licensing terms do not allow you -to copy them wholesale, is as patches. +to copy them wholesale, is as patches. There are many patch formats, which I'll cover in brief. The common theme is that you can’t distribute anything that wasn't made by you. Patches are files that describe how your modified file differs from the original one, thus avoiding the issue of distributing someone else’s original work. -Users who have a copy of the game just need to apply the patch, and those who don’t are unable to play. +Users who have a copy of the game just need to apply the patch, and those who don’t are unable to play. ### Patches #### IPS + IPS patches are a simple list of chunks to replace in the original to generate the output. It is not possible to encode moving of a chunk, so they may inadvertently contain copyrighted material and should be avoided unless you know it's fine. #### UPS, BPS, VCDIFF (xdelta), bsdiff + Other patch formats generate the difference between two streams (delta patches) with varying complexity. This way it is possible to insert bytes or move chunks without including any original data. Bsdiff is highly optimized and includes compression, so this format is used by APBP. @@ -217,6 +209,7 @@ Only a bsdiff module is integrated into AP. If the final patch requires or is ba bsdiff or APBP before adding it to the AP source code as "basepatch.bsdiff4" or "basepatch.apbp". #### APBP Archipelago Binary Patch + Starting with version 4 of the APBP format, this is a ZIP file containing metadata in `archipelago.json` and additional files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the bsdiff between the original and the randomized ROM. @@ -224,121 +217,53 @@ bsdiff between the original and the randomized ROM. To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`. ### Mod files + Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere. Mod files come in many forms, but the rules about not distributing other people's content remain the same. They can either be generic and modify the game using a seed or `slot_data` from the AP websocket, or they can be -generated per seed. +generated per seed. If at all possible, it's generally best practice to collect your world information from `slot_data` +so that the users don't have to move files around in order to play. If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy integration into the Webhost by inheriting from `worlds.Files.APContainer`. - ## Archipelago Integration -Integrating a randomizer into Archipelago involves a few steps. -There are several things that may need to be done, but the most important is to create an implementation of the -`World` class specific to your game. This implementation should exist as a Python module within the `worlds` folder -in the Archipelago file structure. -This encompasses most of the data for your game – the items available, what checks you have, the logic for reaching those -checks, what options to offer for the player’s yaml file, and the code to initialize all this data. +In order for your game to communicate with the Archipelago server and generate the necessary randomized information, +you must create a world package in the main Archipelago repo. This section will cover the requisites and expectations +and show the basics of a world. More in depth documentation on the available API can be read in +the [world api doc.](/docs/world%20api.md) +For setting up your working environment with Archipelago refer +to [running from source](/docs/running%20from%20source.md) and the [style guide](/docs/style.md). -Here’s an example of what your world module can look like: - -![Example world module directory open in Window's Explorer](./img/archipelago-world-directory-example.png) +### Requirements -The minimum requirements for a new archipelago world are the package itself (the world folder containing a file named `__init__.py`), -which must define a `World` class object for the game with a game name, create an equal number of items and locations with rules, -a win condition, and at least one `Region` object. - -Let's give a quick breakdown of what the contents for these files look like. -This is just one example of an Archipelago world - the way things are done below is not an immutable property of Archipelago. - -### Items.py -This file is used to define the items which exist in a given game. - -![Example Items.py file open in Notepad++](./img/example-items-py-file.png) - -Some important things to note here. The center of our Items.py file is the item_table, which individually lists every -item in the game and associates them with an ItemData. +A world implementation requires a few key things from its implementation -This file is rather skeletal - most of the actual data has been stripped out for simplicity. -Each ItemData gives a numeric ID to associate with the item and a boolean telling us whether the item might allow the -player to do more than they would have been able to before. - -Next there's the item_frequencies. This simply tells Archipelago how many times each item appears in the pool. -Items that appear exactly once need not be listed - Archipelago will interpret absence from this dictionary as meaning -that the item appears once. - -Lastly, note the `lookup_id_to_name` dictionary, which is typically imported and used in your Archipelago `World` -implementation. This is how Archipelago is told about the items in your world. - -### Locations.py -This file lists all locations in the game. - -![Example Locations.py file open in Notepad++](./img/example-locations-py-file.png) - -First is the achievement_table. It lists each location, the region that it can be found in (more on regions later), -and a numeric ID to associate with each location. - -The exclusion table is a series of dictionaries which are used to exclude certain checks from the pool of progression -locations based on user settings, and the events table associates certain specific checks with specific items. - -`lookup_id_to_name` is also present for locations, though this is a separate dictionary, to be clear. - -### Options.py -This file details options to be searched for in a player's YAML settings file. - -![Example Options.py file open in Notepad++](./img/example-options-py-file.png) - -There are several types of option Archipelago has support for. -In our case, we have three separate choices a player can toggle, either On or Off. -You can also have players choose between a number of predefined values, or have them provide a numeric value within a -specified range. - -### Regions.py -This file contains data which defines the world's topology. -In other words, it details how different regions of the game connect to each other. - -![Example Regions.py file open in Notepad++](./img/example-regions-py-file.png) - -`terraria_regions` contains a list of tuples. -The first element of the tuple is the name of the region, and the second is a list of connections that lead out of the region. - -`mandatory_connections` describe where the connection leads. - -Above this data is a function called `link_terraria_structures` which uses our defined regions and connections to create -something more usable for Archipelago, but this has been left out for clarity. - -### Rules.py -This is the file that details rules for what players can and cannot logically be required to do, based on items and settings. - -![Example Rules.py file open in Notepad++](./img/example-rules-py-file.png) - -This is the most complicated part of the job, and is one part of Archipelago that is likely to see some changes in the future. -The first class, called `TerrariaLogic`, is an extension of the `LogicMixin` class. -This is where you would want to define methods for evaluating certain conditions, which would then return a boolean to -indicate whether conditions have been met. Your rule definitions should start with some sort of identifier to delineate it -from other games, as all rules are mixed together due to `LogicMixin`. In our case, `_terraria_rule` would be a better name. - -The method below, `set_rules()`, is where you would assign these functions as "rules", using lambdas to associate these -functions or combinations of them (or any other code that evaluates to a boolean, in my case just the placeholder `True`) -to certain tasks, like checking locations or using entrances. - -### \_\_init\_\_.py -This is the file that actually extends the `World` class, and is where you expose functionality and data to Archipelago. - -![Example \_\_init\_\_.py file open in Notepad++](./img/example-init-py-file.png) - -This is the most important file for the implementation, and technically the only one you need, but it's best to keep this -file as short as possible and use other script files to do most of the heavy lifting. -If you've done things well, this will just be where you assign everything you set up in the other files to their associated -fields in the class being extended. - -This is also a good place to put game-specific quirky behavior that needs to be managed, as it tends to make things a bit -cluttered if you put these things elsewhere. - -The various methods and attributes are documented in `/worlds/AutoWorld.py[World]` and -[world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md), -though it is also recommended to look at existing implementations to see how all this works first-hand. -Once you get all that, all that remains to do is test the game and publish your work. -Make sure to check out [world maintainer.md](./world%20maintainer.md) before publishing. +- A folder within `worlds` that contains an `__init__.py` + - This is what defines it as a Python package and how it's able to be imported + into Archipelago's generation system. During generation time only code that is + defined within this file will be run. It's suggested to split up your information + into more files to improve readability, but all of that information can be + imported at its base level within your world. +- A `World` subclass where you create your world and define all of its rules + and the following requirements: + - Your items and locations need a `item_name_to_id` and `location_name_to_id`, + respectively, mapping. + - An `option_definitions` mapping of your game options with the format + `{name: Class}`, where `name` uses Python snake_case. + - You must define your world's `create_item` method, because this may be called + by the generator in certain circumstances + - When creating your world you submit items and regions to the Multiworld. + - These are lists of said objects which you can access at + `self.multiworld.itempool` and `self.multiworld.regions`. Best practice for + adding to these lists is with either `append` or `extend`, where `append` is a + single object and `extend` is a list. + - Do not use `=` as this will delete other worlds' items and regions. + - Regions are containers for holding your world's Locations. + - Locations are where players will "check" for items and must exist within + a region. It's also important for your world's submitted items to be the same as + its submitted locations count. + - You must always have a "Menu" Region from which the generation algorithm + uses to enter the game and access locations. +- Make sure to check out [world maintainer.md](/docs/world%20maintainer.md) before publishing. \ No newline at end of file diff --git a/docs/apworld specification.md b/docs/apworld specification.md index 98cd25a730..ed2e8b1c8e 100644 --- a/docs/apworld specification.md +++ b/docs/apworld specification.md @@ -29,6 +29,7 @@ The zip can contain arbitrary files in addition what was specified above. ## Caveats -Imports from other files inside the apworld have to use relative imports. +Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions` -Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py. +Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or +`from worlds.AutoWorld import World` diff --git a/docs/contributing.md b/docs/contributing.md index 899c06b922..9b5f93e198 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,14 +1,33 @@ # Contributing -Contributions are welcome. We have a few requests of any new contributors. +Contributions are welcome. We have a few requests for new contributors: -* 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). +* **Follow styling guidelines.** + Please take a look at the [code style documentation](/docs/style.md) + to ensure ease of communication and uniformity. -Otherwise, we tend to judge code on a case to case basis. +* **Ensure that critical changes are covered by tests.** +It is strongly recommended that unit tests are used to avoid regression and to ensure everything is still working. +If you wish to contribute by adding a new game, please take a look at the [logic unit test documentation](/docs/tests.md). +If you wish to contribute to the website, please take a look at [these tests](/test/webhost). -For adding a new game to Archipelago and other documentation on how Archipelago functions, please see -[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev -channel in our [Discord](https://archipelago.gg/discord). -If you want to merge a new game, please make sure to read the responsibilities as -[world maintainer](/docs/world%20maintainer.md). +* **Do not introduce unit test failures/regressions.** +Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test +your changes. Currently, the oldest supported version is [Python 3.8](https://www.python.org/downloads/release/python-380/). +It is recommended that automated github actions are turned on in your fork to have github run all of the unit tests after pushing. +You can turn them on here: +![Github actions example](./img/github-actions-example.png) + +Other than these requests, we tend to judge code on a case by case basis. + +For contribution to the website, please refer to the [WebHost README](/WebHostLib/README.md). + +If you want to contribute to the core, you will be subject to stricter review on your pull requests. It is recommended +that you get in touch with other core maintainers via the [Discord](https://archipelago.gg/discord). + +If you want to add Archipelago support for a new game, please take a look at the [adding games documentation](/docs/adding%20games.md), which details what is required +to implement support for a game, as well as tips for how to get started. +If you want to merge a new game into the main Archipelago repo, please make sure to read the responsibilities as a +[world maintainer](/docs/world%20maintainer.md). + +For other questions, feel free to explore the [main documentation folder](/docs/) and ask us questions in the #archipelago-dev channel +of the [Discord](https://archipelago.gg/discord). diff --git a/docs/img/archipelago-world-directory-example.png b/docs/img/archipelago-world-directory-example.png deleted file mode 100644 index ba720f3319..0000000000 Binary files a/docs/img/archipelago-world-directory-example.png and /dev/null differ diff --git a/docs/img/example-init-py-file.png b/docs/img/example-init-py-file.png deleted file mode 100644 index 6dd5c3c938..0000000000 Binary files a/docs/img/example-init-py-file.png and /dev/null differ diff --git a/docs/img/example-items-py-file.png b/docs/img/example-items-py-file.png deleted file mode 100644 index e114a78d21..0000000000 Binary files a/docs/img/example-items-py-file.png and /dev/null differ diff --git a/docs/img/example-locations-py-file.png b/docs/img/example-locations-py-file.png deleted file mode 100644 index 53c4bc1e29..0000000000 Binary files a/docs/img/example-locations-py-file.png and /dev/null differ diff --git a/docs/img/example-options-py-file.png b/docs/img/example-options-py-file.png deleted file mode 100644 index 5811f54000..0000000000 Binary files a/docs/img/example-options-py-file.png and /dev/null differ diff --git a/docs/img/example-regions-py-file.png b/docs/img/example-regions-py-file.png deleted file mode 100644 index a9d05c53fc..0000000000 Binary files a/docs/img/example-regions-py-file.png and /dev/null differ diff --git a/docs/img/example-rules-py-file.png b/docs/img/example-rules-py-file.png deleted file mode 100644 index b76e78b406..0000000000 Binary files a/docs/img/example-rules-py-file.png and /dev/null differ diff --git a/docs/img/github-actions-example.png b/docs/img/github-actions-example.png new file mode 100644 index 0000000000..2363a3ed4c Binary files /dev/null and b/docs/img/github-actions-example.png differ diff --git a/docs/img/heavy-bullets-managed-directory.png b/docs/img/heavy-bullets-managed-directory.png deleted file mode 100644 index 73017f6dc9..0000000000 Binary files a/docs/img/heavy-bullets-managed-directory.png and /dev/null differ diff --git a/docs/network protocol.md b/docs/network protocol.md index d461cebce1..274b6e3716 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -380,11 +380,13 @@ Additional arguments sent in this package will also be added to the [Retrieved]( Some special keys exist with specific return data, all of them have the prefix `_read_`, so `hints_{team}_{slot}` is `_read_hints_{team}_{slot}`. -| Name | Type | Notes | -|-------------------------------|--------------------------|---------------------------------------------------| -| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. | -| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. | -| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. | +| Name | Type | Notes | +|----------------------------------|-------------------------------|-------------------------------------------------------| +| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. | +| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. | +| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. | +| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. | +| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. | ### Set Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package. @@ -415,6 +417,8 @@ The following operations can be applied to a datastorage key | mul | Multiplies the current value of the key by `value`. | | pow | Multiplies the current value of the key to the power of `value`. | | mod | Sets the current value of the key to the remainder after division by `value`. | +| floor | Floors the current value (`value` is ignored). | +| ceil | Ceils the current value (`value` is ignored). | | max | Sets the current value of the key to `value` if `value` is bigger. | | min | Sets the current value of the key to `value` if `value` is lower. | | and | Applies a bitwise AND to the current value of the key with `value`. | @@ -556,7 +560,7 @@ Color options: `player` marks owning player id for location/item, `flags` contains the [NetworkItem](#NetworkItem) flags that belong to the item -### Client States +### ClientStatus An enumeration containing the possible client states that may be used to inform the server in [StatusUpdate](#StatusUpdate). The MultiServer automatically sets the client state to `ClientStatus.CLIENT_CONNECTED` on the first active connection diff --git a/docs/options api.md b/docs/options api.md index fdabd9facd..48a3f763fa 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 +# 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,29 +52,58 @@ 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) ``` +All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes, +strings that match the option attributes after "option_" is stripped, and the attributes themselves. +```python +# options.py +class Logic(Choice): + option_normal = 0 + option_hard = 1 + option_challenging = 2 + option_extreme = 3 + option_insane = 4 + alias_extra_hard = 2 + crazy = 4 # won't be listed as an option and only exists as an attribute on the class +# __init__.py +from .options import Logic + +if self.options.logic: + do_things_for_all_non_normal_logic() +if self.options.logic == 1: + do_hard_things() +elif self.options.logic == "challenging": + do_challenging_things() +elif self.options.logic == Logic.option_extreme: + do_extreme_things() +elif self.options.logic == "crazy": + do_insane_things() +``` ## Generic Option Classes These options are generically available to every game automatically, but can be overridden for slightly different behavior, if desired. See `worlds/soe/Options.py` for an example. @@ -120,7 +153,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 +161,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() ``` @@ -137,13 +170,20 @@ A numeric option allowing a variety of integers including the endpoints. Has a d `range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string comparisons. -### SpecialRange +### NamedRange Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value. +`special_range_names` can be used to +- give descriptive names to certain values from within the range +- add option values above or below the regular range, to be associated with a special meaning + For example: ```python +range_start = 1 +range_end = 99 special_range_names: { "normal": 20, "extreme": 99, + "unlimited": -1, } ``` @@ -160,7 +200,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 c0f4bf5802..b7367308d8 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/tests.md b/docs/tests.md new file mode 100644 index 0000000000..7a3531f0f8 --- /dev/null +++ b/docs/tests.md @@ -0,0 +1,90 @@ +# Archipelago Unit Testing API + +This document covers some of the generic tests available using Archipelago's unit testing system, as well as some basic +steps on how to write your own. + +## Generic Tests + +Some generic tests are run on every World to ensure basic functionality with default options. These basic tests can be +found in the [general test directory](/test/general). + +## Defining World Tests + +In order to run tests from your world, you will need to create a `test` package within your world package. This can be +done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base +for your world tests can be created in this file that you can then import into other modules. + +### WorldTestBase + +In order to test basic functionality of varying options, as well as to test specific edge cases or that certain +interactions in the world interact as expected, you will want to use the [WorldTestBase](/test/bases.py). This class +comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying +options combinations. + +Example `/worlds//test/__init__.py`: + +```python +from test.bases import WorldTestBase + + +class MyGameTestBase(WorldTestBase): + game = "My Game" +``` + +The basic tests that WorldTestBase comes with include `test_all_state_can_reach_everything`, +`test_empty_state_can_reach_something`, and `test_fill`. These test that with all collected items everything is +reachable, with no collected items at least something is reachable, and that a valid multiworld can be completed with +all steps being called, respectively. + +### Writing Tests + +#### Using WorldTestBase + +Adding runs for the basic tests for a different option combination is as easy as making a new module in the test +package, creating a class that inherits from your game's TestBase, and defining the options in a dict as a field on the +class. The new module should be named `test_.py` and have at least one class inheriting from the base, or +define its own testing methods. Newly defined test methods should follow standard PEP8 snake_case format and also start +with `test_`. + +Example `/worlds//test/test_chest_access.py`: + +```python +from . import MyGameTestBase + + +class TestChestAccess(MyGameTestBase): + options = { + "difficulty": "easy", + "final_boss_hp": 4000, + } + + def test_sword_chests(self) -> None: + """Test locations that require a sword""" + locations = ["Chest1", "Chest2"] + items = [["Sword"]] + # This tests that the provided locations aren't accessible without the provided items, but can be accessed once + # the items are obtained. + # This will also check that any locations not provided don't have the same dependency requirement. + # Optionally, passing only_check_listed=True to the method will only check the locations provided. + self.assertAccessDependency(locations, items) +``` + +When tests are run, this class will create a multiworld with a single player having the provided options, and run the +generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld +that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be +overridden. For more information on what methods are available to your class, check the +[WorldTestBase definition](/test/bases.py#L104). + +#### Alternatives to WorldTestBase + +Unit tests can also be created using [TestBase](/test/bases.py#L14) or +[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These +may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for +testing portions of your code that can be tested without relying on a multiworld to be created first. + +## Running Tests + +In PyCharm, running all tests can be done by right-clicking the root `test` directory and selecting `run Python tests`. +If you do not have pytest installed, you may get import failures. To solve this, edit the run configuration, and set the +working directory of the run to the Archipelago directory. If you only want to run your world's defined tests, repeat +the steps for the test directory within your world. diff --git a/docs/triage role expectations.md b/docs/triage role expectations.md new file mode 100644 index 0000000000..5b4cab2275 --- /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 7a7f37b17c..0ab06da656 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -73,6 +73,53 @@ for your world specifically on the webhost: `game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be prefixed with the same string as defined here. Default already has 'en'. +`options_presets` (optional) A `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values +are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names of +the options and the values are the values to be set for that option. These presets will be available for users to select from on the game's options page. + +Note: The values must be a non-aliased value for the option type and can only include the following option types: + + - If you have a `Range`/`NamedRange` option, the value should be an `int` between the `range_start` and `range_end` + values. + - If you have a `NamedRange` option, the value can alternatively be a `str` that is one of the + `special_range_names` keys. + - If you have a `Choice` option, the value should be a `str` that is one of the `option_` values. + - If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`. + - `random` is also a valid value for any of these option types. + +`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on the webhost at this time. + +Here is an example of a defined preset: +```python +# presets.py +options_presets = { + "Limited Potential": { + "progression_balancing": 0, + "fairy_chests_per_zone": 2, + "starting_class": "random", + "chests_per_zone": 30, + "vendors": "normal", + "architect": "disabled", + "gold_gain_multiplier": "half", + "number_of_children": 2, + "free_diary_on_generation": False, + "health_pool": 10, + "mana_pool": 10, + "attack_pool": 10, + "magic_damage_pool": 10, + "armor_pool": 5, + "equip_pool": 10, + "crit_chance_pool": 5, + "crit_damage_pool": 5, + } +} + +# __init__.py +class RLWeb(WebWorld): + options_presets = options_presets + # ... +``` + ### MultiWorld Object The `MultiWorld` object references the whole multiworld (all items and locations @@ -86,9 +133,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 @@ -119,6 +168,38 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being required, and will prevent progression and useful items from being placed at excluded locations. +#### Documenting Locations + +Worlds can optionally provide a `location_descriptions` map which contains +human-friendly descriptions of locations or location groups. These descriptions +will show up in location-selection options in the Weighted Options page. Extra +indentation and single newlines will be collapsed into spaces. + +```python +# Locations.py + +location_descriptions = { + "Red Potion #6": "In a secret destructible block under the second stairway", + "L2 Spaceship": """ + The group of all items in the spaceship in Level 2. + + This doesn't include the item on the spaceship door, since it can be + accessed without the Spaeship Key. + """ +} +``` + +```python +# __init__.py + +from worlds.AutoWorld import World +from .Locations import location_descriptions + + +class MyGameWorld(World): + location_descriptions = location_descriptions +``` + ### Items Items are all things that can "drop" for your game. This may be RPG items like @@ -145,6 +226,37 @@ Other classifications include * `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that will not be moved around by progression balancing; used, e.g., for currency or tokens +#### Documenting Items + +Worlds can optionally provide an `item_descriptions` map which contains +human-friendly descriptions of items or item groups. These descriptions will +show up in item-selection options in the Weighted Options page. Extra +indentation and single newlines will be collapsed into spaces. + +```python +# Items.py + +item_descriptions = { + "Red Potion": "A standard health potion", + "Spaceship Key": """ + The key to the spaceship in Level 2. + + This is necessary to get to the Star Realm. + """ +} +``` + +```python +# __init__.py + +from worlds.AutoWorld import World +from .Items import item_descriptions + + +class MyGameWorld(World): + item_descriptions = item_descriptions +``` + ### Events Events will mark some progress. You define an event location, an @@ -221,11 +333,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`. +When imported names pile up it may be easier to use `from . import 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 @@ -246,7 +358,7 @@ class MyGameItem(Item): game: str = "My Game" ``` By convention this class definition will either be placed in your `__init__.py` -or your `Items.py`. For a more elaborate example see `worlds/oot/Items.py`. +or your `items.py`. For a more elaborate example see `worlds/oot/Items.py`. ### Your location type @@ -258,30 +370,31 @@ class MyGameLocation(Location): game: str = "My Game" # override constructor to automatically mark event locations as such - def __init__(self, player: int, name = "", code = None, parent = None): + def __init__(self, player: int, name = "", code = None, parent = None) -> None: super(MyGameLocation, self).__init__(player, name, code, parent) self.event = code is None ``` -in your `__init__.py` or your `Locations.py`. +in your `__init__.py` or your `locations.py`. ### Options -By convention options are defined in `Options.py` and will be used when parsing +By convention options are defined in `options.py` and will be used when parsing the players' yaml files. 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. #### Toggle, DefaultOnToggle -Those don't need any additional properties defined. After parsing the option, +These don't need any additional properties defined. After parsing the option, its `value` will either be True or False. #### Range @@ -307,10 +420,10 @@ default = 0 #### Sample ```python -# Options.py +# 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 +446,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 +476,14 @@ class MyGameWorld(World): import settings import typing -from .Options import mygame_options # 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 .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 +492,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 +503,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 @@ -417,7 +537,7 @@ The world has to provide the following things for generation * additions to the regions list: at least one called "Menu" * locations placed inside those regions * a `def create_item(self, item: str) -> MyGameItem` to create any item on demand -* applying `self.multiworld.push_precollected` for start inventory +* applying `self.multiworld.push_precollected` for world defined start inventory * `required_client_version: Tuple[int, int, int]` Optional client version as tuple of 3 ints to make sure the client is compatible to this world (e.g. implements all required features) when connecting. @@ -427,31 +547,32 @@ In addition, the following methods can be implemented and are called in this ord * `stage_assert_generate(cls, multiworld)` is a class method called at the start of generation to check the existence of prerequisite files, usually a ROM for games which require one. -* `def generate_early(self)` - called per player before any items or locations are created. You can set - properties on your world here. Already has access to player options and RNG. -* `def create_regions(self)` +* `generate_early(self)` + called per player before any items or locations are created. You can set properties on your world here. Already has + access to player options and RNG. This is the earliest step where the world should start setting up for the current + multiworld as any steps before this, the multiworld itself is still getting set up +* `create_regions(self)` called to place player's regions and their locations into the MultiWorld's regions list. If it's hard to separate, this can be done during `generate_early` or `create_items` as well. -* `def create_items(self)` +* `create_items(self)` called to place player's items into the MultiWorld's itempool. After this step all regions and items have to be in the MultiWorld's regions and itempool, and these lists should not be modified afterwards. -* `def set_rules(self)` +* `set_rules(self)` called to set access and item rules on locations and entrances. Locations have to be defined before this, or rule application can miss them. -* `def generate_basic(self)` +* `generate_basic(self)` called after the previous steps. Some placement and player specific randomizations can be done here. -* `pre_fill`, `fill_hook` and `post_fill` are called to modify item placement +* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)` are called to modify item placement before, during and after the regular fill process, before `generate_output`. If items need to be placed during pre_fill, these items can be determined and created using `get_prefill_items` -* `def generate_output(self, output_directory: str)` that creates the output +* `generate_output(self, output_directory: str)` that creates the output files if there is output to be generated. When this is called, `self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the item. `location.item.player` can be used to see if it's a local item. -* `fill_slot_data` and `modify_multidata` can be used to modify the data that +* `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that will be used by the server to host the MultiWorld. @@ -460,7 +581,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 @@ -468,9 +589,9 @@ def generate_early(self) -> None: ```python # we need a way to know if an item provides progress in the game ("key item") # this can be part of the items definition, or depend on recipe randomization -from .Items import is_progression # this is just a dummy +from .items import is_progression # this is just a dummy -def create_item(self, item: str): +def create_item(self, item: str) -> MyGameItem: # This is called when AP wants to create an item by name (for plando) or # when you call it from your own code. classification = ItemClassification.progression if is_progression(item) else \ @@ -478,7 +599,7 @@ def create_item(self, item: str): return MyGameItem(item, classification, self.item_name_to_id[item], self.player) -def create_event(self, event: str): +def create_event(self, event: str) -> MyGameItem: # while we are at it, we can also add a helper to create events return MyGameItem(event, True, None, self.player) ``` @@ -559,13 +680,19 @@ 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 ```python -from worlds.generic.Rules import add_rule, set_rule, forbid_item -from Items import get_item_type +from worlds.generic.Rules import add_rule, set_rule, forbid_item, add_item_rule +from .items import get_item_type def set_rules(self) -> None: @@ -591,7 +718,7 @@ def set_rules(self) -> None: # require one item from an item group add_rule(self.multiworld.get_location("Chest3", self.player), lambda state: state.has_group("weapons", self.player)) - # state also has .item_count() for items, .has_any() and .has_all() for sets + # state also has .count() for items, .has_any() and .has_all() for multiple # and .count_group() for groups # set_rule is likely to be a bit faster than add_rule @@ -634,12 +761,12 @@ Please do this with caution and only when necessary. #### Sample ```python -# Logic.py +# logic.py from worlds.AutoWorld import LogicMixin class MyGameLogic(LogicMixin): - def mygame_has_key(self, player: int): + def mygame_has_key(self, player: int) -> bool: # Arguments above are free to choose # MultiWorld can be accessed through self.multiworld, explicitly passing in # MyGameWorld instance for easy options access is also a valid approach @@ -649,11 +776,11 @@ class MyGameLogic(LogicMixin): # __init__.py from worlds.generic.Rules import set_rule -import .Logic # apply the mixin by importing its file +import .logic # apply the mixin by importing its file class MyGameWorld(World): # ... - def set_rules(self): + def set_rules(self) -> None: set_rule(self.multiworld.get_location("A Door", self.player), lambda state: state.mygame_has_key(self.player)) ``` @@ -661,10 +788,10 @@ class MyGameWorld(World): ### Generate Output ```python -from .Mod import generate_mod +from .mod import generate_mod -def generate_output(self, output_directory: str): +def generate_output(self, output_directory: str) -> None: # How to generate the mod or ROM highly depends on the game # if the mod is written in Lua, Jinja can be used to fill a template # if the mod reads a json file, `json.dump()` can be used to generate that @@ -679,12 +806,10 @@ def generate_output(self, output_directory: str): # make sure to mark as not remote_start_inventory when connecting if stored in rom/mod "starter_items": [item.name for item 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, - # store option value True or False for fixing a glitch - "fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value, } + + # add needed option results to the dictionary + data.update(self.options.as_dict("final_boss_hp", "difficulty", "fix_xyz_glitch")) # point to a ROM specified by the installation src = self.settings.rom_file # or point to worlds/mygame/data/mod_template @@ -696,6 +821,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) -> Dict[str, Any]: + # 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 +868,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.bases import WorldTestBase class MyGameTestBase(WorldTestBase): @@ -733,23 +879,25 @@ class MyGameTestBase(WorldTestBase): Next using the rules defined in the above `set_rules` we can test that the chests have the correct access rules. -Example `testChestAccess.py` +Example `test_chest_access.py` ```python from . import MyGameTestBase class TestChestAccess(MyGameTestBase): - def test_sword_chests(self): + def test_sword_chests(self) -> None: """Test locations that require a sword""" locations = ["Chest1", "Chest2"] items = [["Sword"]] # this will test that each location can't be accessed without the "Sword", but can be accessed once obtained. self.assertAccessDependency(locations, items) - def test_any_weapon_chests(self): + def test_any_weapon_chests(self) -> None: """Test locations that require any weapon""" locations = [f"Chest{i}" for i in range(3, 6)] items = [["Sword"], ["Axe"], ["Spear"]] # this will test that chests 3-5 can't be accessed without any weapon, but can be with just one of them. self.assertAccessDependency(locations, items) ``` + +For more information on tests check the [tests doc](tests.md). diff --git a/inno_setup.iss b/inno_setup.iss index 147cd74dca..be5de320a1 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -46,147 +46,33 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Types] Name: "full"; Description: "Full installation" -Name: "hosting"; Description: "Installation for hosting purposes" -Name: "playing"; Description: "Installation for playing purposes" +Name: "minimal"; Description: "Minimal installation" Name: "custom"; Description: "Custom installation"; Flags: iscustom [Components] -Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed -Name: "generator"; Description: "Generator"; Types: full hosting -Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning -Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning -Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 -Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning -Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning -Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting -Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting -Name: "generator/mmbn3"; Description: "MegaMan Battle Network 3"; Types: full hosting; ExtraDiskSpaceRequired: 8388608; Flags: disablenouninstallwarning -Name: "generator/ladx"; Description: "Link's Awakening DX ROM Setup"; Types: full hosting -Name: "generator/tloz"; Description: "The Legend of Zelda ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 135168; Flags: disablenouninstallwarning -Name: "server"; Description: "Server"; Types: full hosting -Name: "client"; Description: "Clients"; Types: full playing -Name: "client/sni"; Description: "SNI Client"; Types: full playing -Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning -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/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 -Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing -Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing -Name: "client/pkmn"; Description: "Pokemon Client" -Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576 -Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576 -Name: "client/mmbn3"; Description: "MegaMan Battle Network 3 Client"; Types: full playing; -Name: "client/ladx"; Description: "Link's Awakening Client"; Types: full playing; ExtraDiskSpaceRequired: 1048576 -Name: "client/cf"; Description: "ChecksFinder"; Types: full playing -Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing -Name: "client/wargroove"; Description: "Wargroove"; Types: full playing -Name: "client/zl"; Description: "Zillion"; Types: full playing -Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing -Name: "client/advn"; Description: "Adventure"; Types: full playing -Name: "client/ut"; Description: "Undertale"; Types: full playing -Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing +Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed +Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full; [Dirs] NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; [Files] -Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp -Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm -Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3 -Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw -Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe -Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac -Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot -Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl -Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r -Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b -Source: "{code:GetBN3ROMPath}"; DestDir: "{app}"; DestName: "Mega Man Battle Network 3 - Blue Version (USA).gba"; Flags: external; Components: client/mmbn3 -Source: "{code:GetLADXROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"; Flags: external; Components: client/ladx or generator/ladx -Source: "{code:GetTLoZROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The (U) (PRG0) [!].nes"; Flags: external; Components: client/tloz or generator/tloz -Source: "{code:GetAdvnROMPath}"; DestDir: "{app}"; DestName: "ADVNTURE.BIN"; Flags: external; Components: client/advn -Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni -Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp - -Source: "{#source_path}\ArchipelagoLauncher.exe"; DestDir: "{app}"; Flags: ignoreversion; -Source: "{#source_path}\ArchipelagoLauncher(DEBUG).exe"; DestDir: "{app}"; Flags: ignoreversion; -Source: "{#source_path}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator -Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server -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}\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 -Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot -Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot -Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl -Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1 -Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn -Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf -Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2 -Source: "{#source_path}\ArchipelagoMMBN3Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/mmbn3 -Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/tloz -Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove -Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2 -Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn -Source: "{#source_path}\ArchipelagoUndertaleClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ut +Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; +Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall [Icons] Name: "{group}\{#MyAppName} Folder"; Filename: "{app}"; 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} 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 -Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl -Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1 -Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn -Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf -Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2 -Name: "{group}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Components: client/mmbn3 -Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Components: client/tloz -Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2 -Name: "{group}\{#MyAppName} Link's Awakening Client"; Filename: "{app}\ArchipelagoLinksAwakeningClient.exe"; Components: client/ladx -Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn -Name: "{group}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Components: client/wargroove -Name: "{group}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Components: client/ut Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon 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} 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 -Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl -Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1 -Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn -Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf -Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2 -Name: "{commondesktop}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Tasks: desktopicon; Components: client/mmbn3 -Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Tasks: desktopicon; Components: client/tloz -Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove -Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2 -Name: "{commondesktop}\{#MyAppName} Link's Awakening Client"; Filename: "{app}\ArchipelagoLinksAwakeningClient.exe"; Tasks: desktopicon; Components: client/ladx -Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn -Name: "{commondesktop}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Tasks: desktopicon; Components: client/ut [Run] Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..." -Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp -Filename: "{app}\ArchipelagoMinecraftClient.exe"; Parameters: "--install"; StatusMsg: "Installing Forge Server..."; Components: client/minecraft +Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent @@ -194,7 +80,10 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC Type: dirifempty; Name: "{app}" [InstallDelete] +Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld" Type: files; Name: "{app}\ArchipelagoLttPClient.exe" +Type: files; Name: "{app}\ArchipelagoPokemonClient.exe" +Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua" Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*" Type: filesandordirs; Name: "{app}\SNI\lua*" Type: filesandordirs; Name: "{app}\EnemizerCLI*" @@ -202,101 +91,102 @@ Type: filesandordirs; Name: "{app}\EnemizerCLI*" [Registry] -Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl -Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl -Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl -Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl +Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni -Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft -Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft -Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft -Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/minecraft +Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/oot -Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/oot -Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot -Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot +Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn +Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn +Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/mmbn3 -Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/mmbn3 -Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: ""; Components: client/mmbn3 -Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/mmbn3 +Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/ladx -Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/ladx -Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; Components: client/ladx -Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/ladx +Root: HKCR; Subkey: ".apemerald"; ValueData: "{#MyAppName}pkmnepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnepatch"; ValueData: "Archipelago Pokemon Emerald Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/tloz -Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/tloz -Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; Components: client/tloz -Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/tloz +Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/advn -Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/advn -Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; Components: client/advn -Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/advn +Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server -Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server -Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server -Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server +Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text -Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text -Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text -Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text +Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; + +Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; +Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; +Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; +Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; [Code] -const - SHCONTCH_NOPROGRESSBOX = 4; - SHCONTCH_RESPONDYESTOALL = 16; - // See: https://stackoverflow.com/a/51614652/2287576 function IsVCRedist64BitNeeded(): boolean; var @@ -316,594 +206,3 @@ begin Result := True; end; end; - -var R : longint; - -var lttprom: string; -var LttPROMFilePage: TInputFileWizardPage; - -var smrom: string; -var SMRomFilePage: TInputFileWizardPage; - -var dkc3rom: string; -var DKC3RomFilePage: TInputFileWizardPage; - -var smwrom: string; -var SMWRomFilePage: TInputFileWizardPage; - -var soerom: string; -var SoERomFilePage: TInputFileWizardPage; - -var l2acrom: string; -var L2ACROMFilePage: TInputFileWizardPage; - -var ootrom: string; -var OoTROMFilePage: TInputFileWizardPage; - -var zlrom: string; -var ZlROMFilePage: TInputFileWizardPage; - -var redrom: string; -var RedROMFilePage: TInputFileWizardPage; - -var bluerom: string; -var BlueROMFilePage: TInputFileWizardPage; - -var bn3rom: string; -var BN3ROMFilePage: TInputFileWizardPage; - -var ladxrom: string; -var LADXROMFilePage: TInputFileWizardPage; - -var tlozrom: string; -var TLoZROMFilePage: TInputFileWizardPage; - -var advnrom: string; -var AdvnROMFilePage: TInputFileWizardPage; - -function GetSNESMD5OfFile(const rom: string): string; -var data: AnsiString; -begin - if LoadStringFromFile(rom, data) then - begin - if Length(data) mod 1024 = 512 then - begin - data := copy(data, 513, Length(data)-512); - end; - Result := GetMD5OfString(data); - end; -end; - -function GetSMSMD5OfFile(const rom: string): string; -var data: AnsiString; -begin - if LoadStringFromFile(rom, data) then - begin - Result := GetMD5OfString(data); - end; -end; - -function CheckRom(name: string; hash: string): string; -var rom: string; -begin - log('Handling ' + name) - rom := FileSearch(name, WizardDirValue()); - if Length(rom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash))); - if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then - begin - log('existing ROM verified'); - Result := rom; - exit; - end; - log('existing ROM failed verification'); - end; -end; - -function CheckSMSRom(name: string; hash: string): string; -var rom: string; -begin - log('Handling ' + name) - rom := FileSearch(name, WizardDirValue()); - if Length(rom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash))); - if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then - begin - log('existing ROM verified'); - Result := rom; - exit; - end; - log('existing ROM failed verification'); - end; -end; - -function CheckNESRom(name: string; hash: string): string; -var rom: string; -begin - log('Handling ' + name) - rom := FileSearch(name, WizardDirValue()); - if Length(rom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash))); - if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then - begin - log('existing ROM verified'); - Result := rom; - exit; - end; - log('existing ROM failed verification'); - end; -end; - -function AddRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'SNES ROM files|*.sfc;*.smc|All files|*.*', - '.sfc'); -end; - - -function AddGBRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'GB ROM files|*.gb;*.gbc|All files|*.*', - '.gb'); -end; - -function AddGBARomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - Result.Add( - 'Location of ROM file:', - 'GBA ROM files|*.gba|All files|*.*', - '.gba'); -end; - -function AddSMSRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - Result.Add( - 'Location of ROM file:', - 'SMS ROM files|*.sms|All files|*.*', - '.sms'); -end; - -function AddNESRomPage(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'NES ROM files|*.nes|All files|*.*', - '.nes'); -end; - -procedure AddOoTRomPage(); -begin - ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue()); - if Length(ootrom) > 0 then - begin - log('existing ROM found'); - log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6'))); // normal - log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b'))); // byteswapped - log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f'))); // decompressed - if (CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6') = 0) or (CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b') = 0) or (CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f') = 0) then - begin - log('existing ROM verified'); - exit; - end; - log('existing ROM failed verification'); - end; - ootrom := '' - OoTROMFilePage := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your OoT 1.0 ROM located?', - 'Select the file, then click Next.'); - - OoTROMFilePage.Add( - 'Location of ROM file:', - 'N64 ROM files (*.z64, *.n64)|*.z64;*.n64|All files|*.*', - '.z64'); -end; - -function AddA26Page(name: string): TInputFileWizardPage; -begin - Result := - CreateInputFilePage( - wpSelectComponents, - 'Select ROM File', - 'Where is your ' + name + ' located?', - 'Select the file, then click Next.'); - - Result.Add( - 'Location of ROM file:', - 'A2600 ROM files|*.BIN;*.a26|All files|*.*', - '.BIN'); -end; - -function NextButtonClick(CurPageID: Integer): Boolean; -begin - if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then - Result := not (LttPROMFilePage.Values[0] = '') - else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then - Result := not (SMROMFilePage.Values[0] = '') - else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then - Result := not (DKC3ROMFilePage.Values[0] = '') - else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then - Result := not (SMWROMFilePage.Values[0] = '') - else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then - Result := not (SoEROMFilePage.Values[0] = '') - else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then - Result := not (L2ACROMFilePage.Values[0] = '') - else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then - Result := not (OoTROMFilePage.Values[0] = '') - else if (assigned(BN3ROMFilePage)) and (CurPageID = BN3ROMFilePage.ID) then - Result := not (BN3ROMFilePage.Values[0] = '') - else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then - Result := not (ZlROMFilePage.Values[0] = '') - else if (assigned(RedROMFilePage)) and (CurPageID = RedROMFilePage.ID) then - Result := not (RedROMFilePage.Values[0] = '') - else if (assigned(BlueROMFilePage)) and (CurPageID = BlueROMFilePage.ID) then - Result := not (BlueROMFilePage.Values[0] = '') - else if (assigned(LADXROMFilePage)) and (CurPageID = LADXROMFilePage.ID) then - Result := not (LADXROMFilePage.Values[0] = '') - else if (assigned(TLoZROMFilePage)) and (CurPageID = TLoZROMFilePage.ID) then - Result := not (TLoZROMFilePage.Values[0] = '') - else if (assigned(AdvnROMFilePage)) and (CurPageID = AdvnROMFilePage.ID) then - Result := not (AdvnROMFilePage.Values[0] = '') - else - Result := True; -end; - -function GetROMPath(Param: string): string; -begin - if Length(lttprom) > 0 then - Result := lttprom - else if Assigned(LttPRomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') - if R <> 0 then - MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := LttPROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetSMROMPath(Param: string): string; -begin - if Length(smrom) > 0 then - Result := smrom - else if Assigned(SMRomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') - if R <> 0 then - MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := SMROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetDKC3ROMPath(Param: string): string; -begin - if Length(dkc3rom) > 0 then - Result := dkc3rom - else if Assigned(DKC3RomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(DKC3ROMFilePage.Values[0]), '120abf304f0c40fe059f6a192ed4f947') - if R <> 0 then - MsgBox('Donkey Kong Country 3 ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := DKC3ROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetSMWROMPath(Param: string): string; -begin - if Length(smwrom) > 0 then - Result := smwrom - else if Assigned(SMWRomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804') - if R <> 0 then - MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := SMWROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetSoEROMPath(Param: string): string; -begin - if Length(soerom) > 0 then - Result := soerom - else if Assigned(SoERomFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') - if R <> 0 then - MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := SoEROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetOoTROMPath(Param: string): string; -begin - if Length(ootrom) > 0 then - Result := ootrom - else if Assigned(OoTROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f'); - if R <> 0 then - MsgBox('OoT ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := OoTROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetL2ACROMPath(Param: string): string; -begin - if Length(l2acrom) > 0 then - Result := l2acrom - else if Assigned(L2ACROMFilePage) then - begin - R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d') - if R <> 0 then - MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := L2ACROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetZlROMPath(Param: string): string; -begin - if Length(zlrom) > 0 then - Result := zlrom - else if Assigned(ZlROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270'); - if R <> 0 then - MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := ZlROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetRedROMPath(Param: string): string; -begin - if Length(redrom) > 0 then - Result := redrom - else if Assigned(RedROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc') - if R <> 0 then - MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := RedROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetBlueROMPath(Param: string): string; -begin - if Length(bluerom) > 0 then - Result := bluerom - else if Assigned(BlueROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b') - if R <> 0 then - MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := BlueROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetTLoZROMPath(Param: string): string; -begin - if Length(tlozrom) > 0 then - Result := tlozrom - else if Assigned(TLoZROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(TLoZROMFilePage.Values[0]), '337bd6f1a1163df31bf2633665589ab0'); - if R <> 0 then - MsgBox('The Legend of Zelda ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := TLoZROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetLADXROMPath(Param: string): string; -begin - if Length(ladxrom) > 0 then - Result := ladxrom - else if Assigned(LADXROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(LADXROMFilePage.Values[0]), '07c211479386825042efb4ad31bb525f') - if R <> 0 then - MsgBox('Link''s Awakening DX ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := LADXROMFilePage.Values[0] - end - else - Result := ''; - end; - -function GetAdvnROMPath(Param: string): string; -begin - if Length(advnrom) > 0 then - Result := advnrom - else if Assigned(AdvnROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(AdvnROMFilePage.Values[0]), '157bddb7192754a45372be196797f284'); - if R <> 0 then - MsgBox('Adventure ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := AdvnROMFilePage.Values[0] - end - else - Result := ''; -end; - -function GetBN3ROMPath(Param: string): string; -begin - if Length(bn3rom) > 0 then - Result := bn3rom - else if Assigned(BN3ROMFilePage) then - begin - R := CompareStr(GetMD5OfFile(BN3ROMFilePage.Values[0]), '6fe31df0144759b34ad666badaacc442') - if R <> 0 then - MsgBox('MegaMan Battle Network 3 Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); - - Result := BN3ROMFilePage.Values[0] - end - else - Result := ''; - end; - -procedure InitializeWizard(); -begin - AddOoTRomPage(); - - lttprom := CheckRom('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', '03a63945398191337e896e5771f77173'); - if Length(lttprom) = 0 then - LttPROMFilePage:= AddRomPage('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc'); - - smrom := CheckRom('Super Metroid (JU).sfc', '21f3e98df4780ee1c667b84e57d88675'); - if Length(smrom) = 0 then - SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc'); - - dkc3rom := CheckRom('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc', '120abf304f0c40fe059f6a192ed4f947'); - if Length(dkc3rom) = 0 then - DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc'); - - smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804'); - if Length(smwrom) = 0 then - SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc'); - - soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a'); - if Length(soerom) = 0 then - SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc'); - - zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270'); - if Length(zlrom) = 0 then - ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms'); - - redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc'); - if Length(redrom) = 0 then - RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb'); - - bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b'); - if Length(bluerom) = 0 then - BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb'); - - bn3rom := CheckRom('Mega Man Battle Network 3 - Blue Version (USA).gba','6fe31df0144759b34ad666badaacc442'); - if Length(bn3rom) = 0 then - BN3ROMFilePage:= AddGBARomPage('Mega Man Battle Network 3 - Blue Version (USA).gba'); - - ladxrom := CheckRom('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc','07c211479386825042efb4ad31bb525f'); - if Length(ladxrom) = 0 then - LADXROMFilePage:= AddGBRomPage('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc'); - - l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d'); - if Length(l2acrom) = 0 then - L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc'); - - tlozrom := CheckNESROM('Legend of Zelda, The (U) (PRG0) [!].nes', '337bd6f1a1163df31bf2633665589ab0'); - if Length(tlozrom) = 0 then - TLoZROMFilePage:= AddNESRomPage('Legend of Zelda, The (U) (PRG0) [!].nes'); - - advnrom := CheckSMSRom('ADVNTURE.BIN', '157bddb7192754a45372be196797f284'); - if Length(advnrom) = 0 then - AdvnROMFilePage:= AddA26Page('ADVNTURE.BIN'); -end; - - -function ShouldSkipPage(PageID: Integer): Boolean; -begin - Result := False; - if (assigned(LttPROMFilePage)) and (PageID = LttPROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp')); - if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm')); - if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3')); - if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw')); - if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac')); - if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/soe')); - if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot')); - if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl')); - if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red')); - if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue')); - if (assigned(BN3ROMFilePage)) and (PageID = BN3ROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/mmbn3') or WizardIsComponentSelected('client/mmbn3')); - if (assigned(LADXROMFilePage)) and (PageID = LADXROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/ladx') or WizardIsComponentSelected('client/ladx')); - if (assigned(TLoZROMFilePage)) and (PageID = TLoZROMFilePage.ID) then - Result := not (WizardIsComponentSelected('generator/tloz') or WizardIsComponentSelected('client/tloz')); - if (assigned(AdvnROMFilePage)) and (PageID = AdvnROMFilePage.ID) then - Result := not (WizardIsComponentSelected('client/advn')); -end; diff --git a/kvui.py b/kvui.py index 835f0dad45..22e179d5be 100644 --- a/kvui.py +++ b/kvui.py @@ -5,9 +5,13 @@ 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 - ctypes.windll.shcore.SetProcessDpiAwareness(0) + 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" @@ -15,14 +19,15 @@ os.environ["KIVY_NO_ARGS"] = "1" os.environ["KIVY_LOG_ENABLE"] = "0" import Utils + if Utils.is_frozen(): os.environ["KIVY_DATA_DIR"] = Utils.local_path("data") from kivy.config import Config Config.set("input", "mouse", "mouse,disable_multitouch") -Config.set('kivy', 'exit_on_escape', '0') -Config.set('graphics', 'multisamples', '0') # multisamples crash old intel drivers +Config.set("kivy", "exit_on_escape", "0") +Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers from kivy.app import App from kivy.core.window import Window @@ -55,7 +60,6 @@ from kivy.uix.popup import Popup fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) - from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType from Utils import async_start @@ -74,8 +78,8 @@ class HoverBehavior(object): border_point = ObjectProperty(None) def __init__(self, **kwargs): - self.register_event_type('on_enter') - self.register_event_type('on_leave') + self.register_event_type("on_enter") + self.register_event_type("on_leave") Window.bind(mouse_pos=self.on_mouse_pos) Window.bind(on_cursor_leave=self.on_cursor_leave) super(HoverBehavior, self).__init__(**kwargs) @@ -103,7 +107,7 @@ class HoverBehavior(object): self.dispatch("on_leave") -Factory.register('HoverBehavior', HoverBehavior) +Factory.register("HoverBehavior", HoverBehavior) class ToolTip(Label): @@ -118,6 +122,60 @@ class HovererableLabel(HoverBehavior, Label): pass +class TooltipLabel(HovererableLabel): + tooltip = None + + def create_tooltip(self, text, x, y): + text = text.replace("
    ", "\n").replace("&", "&").replace("&bl;", "[").replace("&br;", "]") + if self.tooltip: + # update + self.tooltip.children[0].text = text + else: + self.tooltip = FloatLayout() + tooltip_label = ToolTip(text=text) + self.tooltip.add_widget(tooltip_label) + fade_in_animation.start(self.tooltip) + App.get_running_app().root.add_widget(self.tooltip) + + # handle left-side boundary to not render off-screen + x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2) + + # position float layout + self.tooltip.x = x - self.tooltip.width / 2 + self.tooltip.y = y - self.tooltip.height / 2 + 48 + + def remove_tooltip(self): + if self.tooltip: + App.get_running_app().root.remove_widget(self.tooltip) + self.tooltip = None + + def on_mouse_pos(self, window, pos): + if not self.get_root_window(): + return # Abort if not displayed + super().on_mouse_pos(window, pos) + if self.refs and self.hovered: + + tx, ty = self.to_widget(*pos, relative=True) + # Why TF is Y flipped *within* the texture? + ty = self.texture_size[1] - ty + hit = False + for uid, zones in self.refs.items(): + for zone in zones: + x, y, w, h = zone + if x <= tx <= w and y <= ty <= h: + self.create_tooltip(uid.split("|", 1)[1], *pos) + hit = True + break + if not hit: + self.remove_tooltip() + + def on_enter(self): + pass + + def on_leave(self): + self.remove_tooltip() + + class ServerLabel(HovererableLabel): def __init__(self, *args, **kwargs): super(HovererableLabel, self).__init__(*args, **kwargs) @@ -186,11 +244,10 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, """ Adds selection and focus behaviour to the view. """ -class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): +class SelectableLabel(RecycleDataViewBehavior, TooltipLabel): """ Add selection support to the Label """ index = None selected = BooleanProperty(False) - tooltip = None def refresh_view_attrs(self, rv, index, data): """ Catch and handle the view changes """ @@ -198,56 +255,6 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): return super(SelectableLabel, self).refresh_view_attrs( rv, index, data) - def create_tooltip(self, text, x, y): - text = text.replace("
    ", "\n").replace('&', '&').replace('&bl;', '[').replace('&br;', ']') - if self.tooltip: - # update - self.tooltip.children[0].text = text - else: - self.tooltip = FloatLayout() - tooltip_label = ToolTip(text=text) - self.tooltip.add_widget(tooltip_label) - fade_in_animation.start(self.tooltip) - App.get_running_app().root.add_widget(self.tooltip) - - # handle left-side boundary to not render off-screen - x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2) - - # position float layout - self.tooltip.x = x - self.tooltip.width / 2 - self.tooltip.y = y - self.tooltip.height / 2 + 48 - - def remove_tooltip(self): - if self.tooltip: - App.get_running_app().root.remove_widget(self.tooltip) - self.tooltip = None - - def on_mouse_pos(self, window, pos): - if not self.get_root_window(): - return # Abort if not displayed - super().on_mouse_pos(window, pos) - if self.refs and self.hovered: - - tx, ty = self.to_widget(*pos, relative=True) - # Why TF is Y flipped *within* the texture? - ty = self.texture_size[1] - ty - hit = False - for uid, zones in self.refs.items(): - for zone in zones: - x, y, w, h = zone - if x <= tx <= w and y <= ty <= h: - self.create_tooltip(uid.split("|", 1)[1], *pos) - hit = True - break - if not hit: - self.remove_tooltip() - - def on_enter(self): - pass - - def on_leave(self): - self.remove_tooltip() - def on_touch_down(self, touch): """ Add selection on touch down """ if super(SelectableLabel, self).on_touch_down(touch): @@ -271,7 +278,7 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): elif not cmdinput.text and text.startswith("Missing: "): cmdinput.text = text.replace("Missing: ", "!hint_location ") - Clipboard.copy(text.replace('&', '&').replace('&bl;', '[').replace('&br;', ']')) + Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]")) return self.parent.select_with_touch(self.index, touch) def apply_selection(self, rv, index, is_selected): @@ -279,9 +286,68 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): self.selected = is_selected +class HintLabel(RecycleDataViewBehavior, BoxLayout): + selected = BooleanProperty(False) + striped = BooleanProperty(False) + index = None + no_select = [] + + def __init__(self): + super(HintLabel, self).__init__() + self.receiving_text = "" + self.item_text = "" + self.finding_text = "" + self.location_text = "" + self.entrance_text = "" + self.found_text = "" + for child in self.children: + child.bind(texture_size=self.set_height) + + def set_height(self, instance, value): + self.height = max([child.texture_size[1] for child in self.children]) + + def refresh_view_attrs(self, rv, index, data): + self.index = index + if "select" in data and not data["select"] and index not in self.no_select: + self.no_select.append(index) + self.striped = data["striped"] + self.receiving_text = data["receiving"]["text"] + self.item_text = data["item"]["text"] + self.finding_text = data["finding"]["text"] + self.location_text = data["location"]["text"] + self.entrance_text = data["entrance"]["text"] + self.found_text = data["found"]["text"] + self.height = self.minimum_height + return super(HintLabel, self).refresh_view_attrs(rv, index, data) + + def on_touch_down(self, touch): + """ Add selection on touch down """ + if super(HintLabel, self).on_touch_down(touch): + return True + if self.index not in self.no_select: + if self.collide_point(*touch.pos): + if self.selected: + self.parent.clear_selection() + else: + text = "".join([self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ", + self.finding_text, "\'s World", (" at " + self.entrance_text) + if self.entrance_text != "Vanilla" + else "", ". (", self.found_text.lower(), ")"]) + temp = MarkupLabel(text).markup + text = "".join( + part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) + Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]")) + return self.parent.select_with_touch(self.index, touch) + + def apply_selection(self, rv, index, is_selected): + """ Respond to the selection of items in the view. """ + if self.index not in self.no_select: + self.selected = is_selected + + class ConnectBarTextInput(TextInput): def insert_text(self, substring, from_undo=False): - s = substring.replace('\n', '').replace('\r', '') + s = substring.replace("\n", "").replace("\r", "") return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo) @@ -299,7 +365,7 @@ class MessageBox(Popup): def __init__(self, title, text, error=False, **kwargs): label = MessageBox.MessageBoxLabel(text=text) separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.] - super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width)+40), + super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40), separator_color=separator_color, **kwargs) self.height += max(0, label.height - 18) @@ -355,11 +421,14 @@ class GameManager(App): # top part server_label = ServerLabel() self.connect_layout.add_widget(server_label) - self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None, + self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", + size_hint_y=None, height=dp(30), multiline=False, write_tab=False) + def connect_bar_validate(sender): if not self.ctx.server: self.connect_button_action(sender) + self.server_connect_bar.bind(on_text_validate=connect_bar_validate) self.connect_layout.add_widget(self.server_connect_bar) self.server_connect_button = Button(text="Connect", size=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None) @@ -380,20 +449,22 @@ class GameManager(App): bridge_logger = logging.getLogger(logger_name) panel = TabbedPanelItem(text=display_name) self.log_panels[display_name] = panel.content = UILog(bridge_logger) - self.tabs.add_widget(panel) + if len(self.logging_pairs) > 1: + # show Archipelago tab if other logging is present + self.tabs.add_widget(panel) + + hint_panel = TabbedPanelItem(text="Hints") + self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser) + self.tabs.add_widget(hint_panel) + + if len(self.logging_pairs) == 1: + self.tabs.default_tab_text = "Archipelago" self.main_area_container = GridLayout(size_hint_y=1, rows=1) self.main_area_container.add_widget(self.tabs) self.grid.add_widget(self.main_area_container) - if len(self.logging_pairs) == 1: - # Hide Tab selection if only one tab - self.tabs.clear_tabs() - self.tabs.do_default_tab = False - self.tabs.current_tab.height = 0 - self.tabs.tab_height = 0 - # bottom part bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None) @@ -419,7 +490,7 @@ class GameManager(App): return self.container def update_texts(self, dt): - if hasattr(self.tabs.content.children[0], 'fix_heights'): + if hasattr(self.tabs.content.children[0], "fix_heights"): self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream if self.ctx.server: self.title = self.base_title + " " + Utils.__version__ + \ @@ -496,6 +567,10 @@ class GameManager(App): if hasattr(self, "energy_link_label"): self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J" + def update_hints(self): + hints = self.ctx.stored_data[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"] + self.log_panels["Hints"].refresh_hints(hints) + # default F1 keybind, opens a settings menu, that seems to break the layout engine once closed def open_settings(self, *largs): pass @@ -510,12 +585,12 @@ class LogtoUI(logging.Handler): def format_compact(record: logging.LogRecord) -> str: if isinstance(record.msg, Exception): return str(record.msg) - return (f'{record.exc_info[1]}\n' if record.exc_info else '') + str(record.msg).split("\n")[0] + return (f"{record.exc_info[1]}\n" if record.exc_info else "") + str(record.msg).split("\n")[0] def handle(self, record: logging.LogRecord) -> None: - if getattr(record, 'skip_gui', False): + if getattr(record, "skip_gui", False): pass # skip output - elif getattr(record, 'compact_gui', False): + elif getattr(record, "compact_gui", False): self.on_log(self.format_compact(record)) else: self.on_log(self.format(record)) @@ -549,6 +624,44 @@ class UILog(RecycleView): element.height = element.texture_size[1] +class HintLog(RecycleView): + header = { + "receiving": {"text": "[u]Receiving Player[/u]"}, + "item": {"text": "[u]Item[/u]"}, + "finding": {"text": "[u]Finding Player[/u]"}, + "location": {"text": "[u]Location[/u]"}, + "entrance": {"text": "[u]Entrance[/u]"}, + "found": {"text": "[u]Status[/u]"}, + "striped": True, + "select": False, + } + + def __init__(self, parser): + super(HintLog, self).__init__() + self.data = [self.header] + self.parser = parser + + def refresh_hints(self, hints): + self.data = [self.header] + striped = False + for hint in hints: + self.data.append({ + "striped": striped, + "receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})}, + "item": {"text": self.parser.handle_node( + {"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})}, + "finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})}, + "location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})}, + "entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text", + "color": "blue", "text": hint["entrance"] + if hint["entrance"] else "Vanilla"})}, + "found": { + "text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red", + "text": "Found" if hint["found"] else "Not Found"})}, + }) + striped = not striped + + class E(ExceptionHandler): logger = logging.getLogger("Client") @@ -596,7 +709,7 @@ class KivyJSONtoTextParser(JSONtoTextParser): f"Type: {SlotType(slot_info.type).name}" if slot_info.group_members: text += f"
    Members:
    " + \ - '
    '.join(self.ctx.player_names[player] for player in slot_info.group_members) + "
    ".join(self.ctx.player_names[player] for player in slot_info.group_members) node.setdefault("refs", []).append(text) return super(KivyJSONtoTextParser, self)._handle_player_id(node) @@ -624,4 +737,3 @@ user_file = Utils.user_path("data", "user.kv") if os.path.exists(user_file): logging.info("Loading user.kv into builder.") Builder.load_file(user_file) - diff --git a/playerSettings.yaml b/playerSettings.yaml index e28963ddb3..f9585da246 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/pytest.ini b/pytest.ini index 5599a3c90f..33e0bab8a9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] -python_files = Test*.py +python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported python_classes = Test -python_functions = test \ No newline at end of file +python_functions = test diff --git a/requirements.txt b/requirements.txt index bfc637a80a..0db55a8035 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,13 @@ colorama>=0.4.5 -websockets>=11.0.3 +websockets>=12.0 PyYAML>=6.0.1 -jellyfish>=1.0.1 +jellyfish>=1.0.3 jinja2>=3.1.2 schema>=0.7.5 -kivy>=2.2.0 -bsdiff4>=1.2.3 -platformdirs>=3.9.1 -certifi>=2023.7.22 -cython>=0.29.35 +kivy>=2.2.1 +bsdiff4>=1.2.4 +platformdirs>=4.0.0 +certifi>=2023.11.17 +cython>=3.0.6 cymem>=2.0.8 +orjson>=3.9.10 \ No newline at end of file diff --git a/settings.py b/settings.py index a7dcbbf8dd..acae86095c 100644 --- a/settings.py +++ b/settings.py @@ -694,6 +694,25 @@ does nothing if not found 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 ce35c0f1cc..c864a8cc9d 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ from pathlib import Path # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it try: - requirement = 'cx-Freeze>=6.15.2' + requirement = 'cx-Freeze>=6.15.10' import pkg_resources try: pkg_resources.require(requirement) @@ -71,8 +71,6 @@ non_apworlds: set = { "Clique", "DLCQuest", "Final Fantasy", - "Hylics 2", - "Kingdom Hearts 2", "Lufia II Ancient Cave", "Meritous", "Ocarina of Time", @@ -370,6 +368,10 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE): 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: @@ -617,7 +619,7 @@ cx_Freeze.setup( "excludes": ["numpy", "Cython", "PySide2", "PIL", "pandas"], "zip_include_packages": ["*"], - "zip_exclude_packages": ["worlds", "sc2"], + "zip_exclude_packages": ["worlds", "sc2", "orjson"], # TODO: remove orjson here once we drop py3.8 support "include_files": [], # broken in cx 6.14.0, we use more special sauce now "include_msvcr": False, "replace_paths": ["*."], diff --git a/test/TestBase.py b/test/TestBase.py index dc79ad2855..bfd92346d3 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 32622f65a9..37ebe3f627 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,3 +1,4 @@ +import pathlib import warnings import settings @@ -5,3 +6,13 @@ import settings 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 0000000000..d6a43c598f --- /dev/null +++ b/test/bases.py @@ -0,0 +1,339 @@ +import random +import sys +import typing +import unittest +from argparse import Namespace + +from Generate import get_seed_name +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 """ + memory_leak_tested: typing.ClassVar[bool] = False + """ remember if memory leak test was already done for this class """ + + def setUp(self) -> None: + if self.auto_construct: + self.world_setup() + + def tearDown(self) -> None: + if self.__class__.memory_leak_tested or not self.options or not self.constructed or \ + sys.version_info < (3, 11, 0): # the leak check in tearDown fails in py<3.11 for an unknown reason + # only run memory leak test once per class, only for constructed with non-default options + # default options will be tested in test/general + super().tearDown() + return + + import gc + import weakref + weak = weakref.ref(self.multiworld) + for attr_name in dir(self): # delete all direct references to MultiWorld and World + attr: object = typing.cast(object, getattr(self, attr_name)) + if type(attr) is MultiWorld or isinstance(attr, AutoWorld.World): + delattr(self, attr_name) + state_cache: typing.Optional[typing.Dict[typing.Any, typing.Any]] = getattr(self, "_state_cache", None) + if state_cache is not None: # in case of multiple inheritance with TestBase, we need to clear its cache + state_cache.clear() + gc.collect() + self.__class__.memory_leak_tested = True + self.assertFalse(weak(), f"World {getattr(self, 'game', '')} leaked MultiWorld object") + super().tearDown() + + 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) + random.seed(self.multiworld.seed) + self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py + 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 = list(self.multiworld.get_locations(1)) + 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/TestIDs.py b/test/general/TestIDs.py deleted file mode 100644 index db1c9461b9..0000000000 --- a/test/general/TestIDs.py +++ /dev/null @@ -1,64 +0,0 @@ -import unittest -from worlds.AutoWorld import AutoWorldRegister - - -class TestIDs(unittest.TestCase): - def testUniqueItems(self): - 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): - 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): - """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): - """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): - """negative item IDs are reserved to the special "Archipelago" world.""" - for gamename, world_type in AutoWorldRegister.world_types.items(): - with self.subTest(game=gamename): - if gamename == "Archipelago": - for item_id in world_type.item_id_to_name: - self.assertLess(item_id, 0) - else: - for item_id in world_type.item_id_to_name: - self.assertGreater(item_id, 0) - - def testReservedLocations(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): - if gamename == "Archipelago": - for location_id in world_type.location_id_to_name: - self.assertLess(location_id, 0) - else: - for location_id in world_type.location_id_to_name: - self.assertGreater(location_id, 0) - - def testDuplicateItemIDs(self): - 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): - 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/__init__.py b/test/general/__init__.py index b0fb7ca32e..5e0f22f4ec 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 83% rename from test/general/TestFill.py rename to test/general/test_fill.py index 99f48cd0c7..e454b3e61d 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 @@ class PlayerDefinition(object): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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 @@ class TestFillRestrictive(unittest.TestCase): 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) @@ -418,7 +442,49 @@ class TestFillRestrictive(unittest.TestCase): self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1") self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1") + def test_swap_to_earlier_location_with_item_rule2(self): + """Test that swap works before all items are placed""" + multi_world = generate_multi_world(1) + player1 = generate_player_data(multi_world, 1, 5, 5) + locations = player1.locations[:] # copy required + items = player1.prog_items[:] # copy required + # Two items provide access to sphere 2. + # One of them is forbidden in sphere 1, the other is first placed in sphere 4 because of placement order, + # requiring a swap. + # There are spheres in between, so for the swap to work, it'll have to assume all other items are collected. + one_to_two1 = items[4].name + one_to_two2 = items[3].name + three_to_four = items[2].name + two_to_three1 = items[1].name + two_to_three2 = items[0].name + # Sphere 4 + set_rule(locations[0], lambda state: ((state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id)) + and state.has(two_to_three1, player1.id) + and state.has(two_to_three2, player1.id) + and state.has(three_to_four, player1.id))) + # Sphere 3 + set_rule(locations[1], lambda state: ((state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id)) + and state.has(two_to_three1, player1.id) + and state.has(two_to_three2, player1.id))) + # Sphere 2 + set_rule(locations[2], lambda state: state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id)) + # Sphere 1 + sphere1_loc1 = locations[3] + sphere1_loc2 = locations[4] + # forbid one_to_two2 in sphere 1 to make the swap happen as described above + add_item_rule(sphere1_loc1, lambda item_to_place: item_to_place.name != one_to_two2) + add_item_rule(sphere1_loc2, lambda item_to_place: item_to_place.name != one_to_two2) + + # Now fill should place one_to_two1 in sphere1_loc1 or sphere1_loc2 via swap, + # which it will attempt before two_to_three and three_to_four are placed, testing the behavior. + fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items) + # assert swap happened + self.assertTrue(sphere1_loc1.item and sphere1_loc2.item, "Did not swap required item into Sphere 1") + self.assertTrue(sphere1_loc1.item.name == one_to_two1 or + sphere1_loc2.item.name == one_to_two1, "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) @@ -430,10 +496,11 @@ class TestFillRestrictive(unittest.TestCase): location.place_locked_item(item) multi_world.state.sweep_for_events() multi_world.state.sweep_for_events() - self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed") - self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times") + self.assertTrue(multi_world.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed") + self.assertEqual(multi_world.state.prog_items[item.player][item.name], 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 +517,7 @@ class TestFillRestrictive(unittest.TestCase): 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 +537,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +552,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +567,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +580,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +595,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +610,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +625,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +655,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +675,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +693,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +712,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +722,14 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +750,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase): 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 +830,22 @@ class TestBalanceMultiworldProgression(unittest.TestCase): # 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 +858,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): 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 +872,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): 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 +886,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): 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 +899,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase): 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 c0b560c7e4..83b56b3438 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 @@ class TestHelpers(unittest.TestCase): 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 @@ class TestHelpers(unittest.TestCase): 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 78% rename from test/general/TestHostYAML.py rename to test/general/test_host_yaml.py index f5fd406cac..79285d3a63 100644 --- a/test/general/TestHostYAML.py +++ b/test/general/test_host_yaml.py @@ -15,14 +15,16 @@ class TestIDs(unittest.TestCase): cls.yaml_options = Utils.parse_yaml(f.read()) def test_utils_in_yaml(self) -> None: - for option_key, option_set in Utils.get_default_options().items(): + """Tests that the auto generated host.yaml has default settings in it""" + for option_key, option_set in Settings(None).items(): with self.subTest(option_key): self.assertIn(option_key, self.yaml_options) for sub_option_key in option_set: self.assertIn(sub_option_key, self.yaml_options[option_key]) def test_yaml_in_utils(self) -> None: - utils_options = Utils.get_default_options() + """Tests that the auto generated host.yaml shows up in reference calls""" + utils_options = Settings(None) for option_key, option_set in self.yaml_options.items(): with self.subTest(option_key): self.assertIn(option_key, utils_options) diff --git a/test/general/test_ids.py b/test/general/test_ids.py new file mode 100644 index 0000000000..98c41b67b1 --- /dev/null +++ b/test/general/test_ids.py @@ -0,0 +1,102 @@ +import unittest + +from Fill import distribute_items_restrictive +from worlds.AutoWorld import AutoWorldRegister, call_all +from . import setup_solo_multiworld + + +class TestIDs(unittest.TestCase): + 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 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 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 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 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): + if gamename == "Archipelago": + for item_id in world_type.item_id_to_name: + self.assertLess(item_id, 0) + else: + for item_id in world_type.item_id_to_name: + self.assertGreater(item_id, 0) + + 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): + if gamename == "Archipelago": + for location_id in world_type.location_id_to_name: + self.assertLess(location_id, 0) + else: + for location_id in world_type.location_id_to_name: + self.assertGreater(location_id, 0) + + 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 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)) + + def test_postgen_datapackage(self): + """Generates a solo multiworld and checks that the datapackage is still valid""" + for gamename, world_type in AutoWorldRegister.world_types.items(): + with self.subTest(game=gamename): + multiworld = setup_solo_multiworld(world_type) + distribute_items_restrictive(multiworld) + call_all(multiworld, "post_fill") + datapackage = world_type.get_data_package_data() + for item_group, item_names in datapackage["item_name_groups"].items(): + self.assertIsInstance(item_group, str, + f"item_name_group names should be strings: {item_group}") + for item_name in item_names: + self.assertIsInstance(item_name, str, + f"{item_name}, in group {item_group} is not a string") + for loc_group, loc_names in datapackage["location_name_groups"].items(): + self.assertIsInstance(loc_group, str, + f"location_name_group names should be strings: {loc_group}") + for loc_name in loc_names: + self.assertIsInstance(loc_name, str, + f"{loc_name}, in group {loc_group} is not a string") + for item_name, item_id in datapackage["item_name_to_id"].items(): + self.assertIsInstance(item_name, str, + f"{item_name} is not a valid item name for item_name_to_id") + self.assertIsInstance(item_id, int, + f"{item_id} for {item_name} should be an int") + for loc_name, loc_id in datapackage["location_name_to_id"].items(): + self.assertIsInstance(loc_name, str, + f"{loc_name} is not a valid item name for location_name_to_id") + self.assertIsInstance(loc_id, int, + f"{loc_id} for {loc_name} should be an int") diff --git a/test/general/TestImplemented.py b/test/general/test_implemented.py similarity index 57% rename from test/general/TestImplemented.py rename to test/general/test_implemented.py index 22c546eff1..624be71018 100644 --- a/test/general/TestImplemented.py +++ b/test/general/test_implemented.py @@ -1,11 +1,13 @@ import unittest -from worlds.AutoWorld import AutoWorldRegister +from Fill import distribute_items_restrictive +from NetUtils import encode +from worlds.AutoWorld import AutoWorldRegister, call_all from . import setup_solo_multiworld 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 +15,7 @@ class TestImplemented(unittest.TestCase): 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 +25,7 @@ class TestImplemented(unittest.TestCase): 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: @@ -31,3 +33,17 @@ class TestImplemented(unittest.TestCase): for method in ("assert_generate",): self.assertFalse(hasattr(world_type, method), f"{method} must be implemented as a @classmethod named stage_{method}.") + + def test_slot_data(self): + """Tests that if a world creates slot data, it's json serializable.""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + # has an await for generate_output which isn't being called + if game_name in {"Ocarina of Time", "Zillion"}: + continue + multiworld = setup_solo_multiworld(world_type) + with self.subTest(game=game_name, seed=multiworld.seed): + distribute_items_restrictive(multiworld) + call_all(multiworld, "post_fill") + for key, data in multiworld.worlds[1].fill_slot_data().items(): + self.assertIsInstance(key, str, "keys in slot data must be a string") + self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.") diff --git a/test/general/TestItems.py b/test/general/test_items.py similarity index 75% rename from test/general/TestItems.py rename to test/general/test_items.py index 95eb8d28d9..2d8775d535 100644 --- a/test/general/TestItems.py +++ b/test/general/test_items.py @@ -4,7 +4,8 @@ from . import setup_solo_multiworld 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 @@ class TestBase(unittest.TestCase): 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 @@ class TestBase(unittest.TestCase): 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 @@ class TestBase(unittest.TestCase): 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) @@ -58,3 +60,12 @@ class TestBase(unittest.TestCase): multiworld = setup_solo_multiworld(world_type) for item in multiworld.itempool: self.assertIn(item.name, world_type.item_name_to_id) + + def test_item_descriptions_have_valid_names(self): + """Ensure all item descriptions match an item name or item group name""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + valid_names = world_type.item_names.union(world_type.item_name_groups) + for name in world_type.item_descriptions: + with self.subTest("Name should be valid", game=game_name, item=name): + self.assertIn(name, valid_names, + "All item descriptions must match defined item names") diff --git a/test/general/TestLocations.py b/test/general/test_locations.py similarity index 83% rename from test/general/TestLocations.py rename to test/general/test_locations.py index e77e7a6332..725b48e62f 100644 --- a/test/general/TestLocations.py +++ b/test/general/test_locations.py @@ -5,7 +5,7 @@ from . import setup_solo_multiworld 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 @@ class TestBase(unittest.TestCase): 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,13 +30,12 @@ class TestBase(unittest.TestCase): 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(): with self.subTest("Game", game_name=game_name): multiworld = setup_solo_multiworld(world_type, gen_steps) - multiworld._recache() region_count = len(multiworld.get_regions()) location_count = len(multiworld.get_locations()) @@ -46,21 +45,19 @@ class TestBase(unittest.TestCase): self.assertEqual(location_count, len(multiworld.get_locations()), f"{game_name} modified locations count during rule creation") - multiworld._recache() call_all(multiworld, "generate_basic") self.assertEqual(region_count, len(multiworld.get_regions()), f"{game_name} modified region count during generate_basic") self.assertGreaterEqual(location_count, len(multiworld.get_locations()), f"{game_name} modified locations count during generate_basic") - multiworld._recache() call_all(multiworld, "pre_fill") self.assertEqual(region_count, len(multiworld.get_regions()), f"{game_name} modified region count during pre_fill") 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): @@ -69,3 +66,12 @@ class TestBase(unittest.TestCase): for location in locations: self.assertIn(location, world_type.location_name_to_id) self.assertNotIn(group_name, world_type.location_name_to_id) + + def test_location_descriptions_have_valid_names(self): + """Ensure all location descriptions match a location name or location group name""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + valid_names = world_type.location_names.union(world_type.location_name_groups) + for name in world_type.location_descriptions: + with self.subTest("Name should be valid", game=game_name, location=name): + self.assertIn(name, valid_names, + "All location descriptions must match defined location names") diff --git a/test/general/test_memory.py b/test/general/test_memory.py new file mode 100644 index 0000000000..e352b9e875 --- /dev/null +++ b/test/general/test_memory.py @@ -0,0 +1,16 @@ +import unittest + +from worlds.AutoWorld import AutoWorldRegister +from . import setup_solo_multiworld + + +class TestWorldMemory(unittest.TestCase): + def test_leak(self): + """Tests that worlds don't leak references to MultiWorld or themselves with default options.""" + import gc + import weakref + for game_name, world_type in AutoWorldRegister.world_types.items(): + with self.subTest("Game", game_name=game_name): + weak = weakref.ref(setup_solo_multiworld(world_type)) + gc.collect() + self.assertFalse(weak(), "World leaked a reference") 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 6dae53240d..7be76eed4b 100644 --- a/test/general/TestNames.py +++ b/test/general/test_names.py @@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister 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 @@ class TestNames(unittest.TestCase): 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 b7058183e0..e1136f93c9 100644 --- a/test/general/TestOptions.py +++ b/test/general/test_options.py @@ -3,9 +3,10 @@ from worlds.AutoWorld import AutoWorldRegister 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 dd786b8352..828912ee35 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 @@ class TestBase(unittest.TestCase): 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 d04e1f2c5b..887a417ec9 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/test_caches.py b/test/utils/test_caches.py new file mode 100644 index 0000000000..fc681611f0 --- /dev/null +++ b/test/utils/test_caches.py @@ -0,0 +1,66 @@ +# Tests for caches in Utils.py + +import unittest +from typing import Any + +from Utils import cache_argsless, cache_self1 + + +class TestCacheArgless(unittest.TestCase): + def test_cache(self) -> None: + @cache_argsless + def func_argless() -> object: + return object() + + self.assertTrue(func_argless() is func_argless()) + + if __debug__: # assert only available with __debug__ + def test_invalid_decorator(self) -> None: + with self.assertRaises(Exception): + @cache_argsless # type: ignore[arg-type] + def func_with_arg(_: Any) -> None: + pass + + +class TestCacheSelf1(unittest.TestCase): + def test_cache(self) -> None: + class Cls: + @cache_self1 + def func(self, _: Any) -> object: + return object() + + o1 = Cls() + o2 = Cls() + self.assertTrue(o1.func(1) is o1.func(1)) + self.assertFalse(o1.func(1) is o1.func(2)) + self.assertFalse(o1.func(1) is o2.func(1)) + + def test_gc(self) -> None: + # verify that we don't keep a global reference + import gc + import weakref + + class Cls: + @cache_self1 + def func(self, _: Any) -> object: + return object() + + o = Cls() + _ = o.func(o) # keep a hard ref to the result + r = weakref.ref(o) # keep weak ref to the cache + del o # remove hard ref to the cache + gc.collect() + self.assertFalse(r()) # weak ref should be dead now + + if __debug__: # assert only available with __debug__ + def test_no_self(self) -> None: + with self.assertRaises(Exception): + @cache_self1 # type: ignore[arg-type] + def func() -> Any: + pass + + def test_too_many_args(self) -> None: + with self.assertRaises(Exception): + @cache_self1 # type: ignore[arg-type] + def func(_1: Any, _2: Any, _3: Any) -> Any: + pass 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 93% rename from test/webhost/TestAPIGenerate.py rename to test/webhost/test_api_generate.py index 8ea78f27f9..b8bdcb38c7 100644 --- a/test/webhost/TestAPIGenerate.py +++ b/test/webhost/test_api_generate.py @@ -19,11 +19,11 @@ class TestDocs(unittest.TestCase): 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 f6ede1543e..68aba05f9d 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 @@ class TestDocs(unittest.TestCase): 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 96% rename from test/webhost/TestFileGeneration.py rename to test/webhost/test_file_generation.py index f01b70e14f..059f6b49a1 100644 --- a/test/webhost/TestFileGeneration.py +++ b/test/webhost/test_file_generation.py @@ -13,7 +13,7 @@ class TestFileGeneration(unittest.TestCase): # should not create the folder *here* cls.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib") - def testOptions(self): + 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") @@ -30,7 +30,7 @@ class TestFileGeneration(unittest.TestCase): 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/webhost/test_option_presets.py b/test/webhost/test_option_presets.py new file mode 100644 index 0000000000..0c88b6c2ee --- /dev/null +++ b/test/webhost/test_option_presets.py @@ -0,0 +1,63 @@ +import unittest + +from worlds import AutoWorldRegister +from Options import Choice, NamedRange, Toggle, Range + + +class TestOptionPresets(unittest.TestCase): + def test_option_presets_have_valid_options(self): + """Test that all predefined option presets are valid options.""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + presets = world_type.web.options_presets + for preset_name, preset in presets.items(): + for option_name, option_value in preset.items(): + with self.subTest(game=game_name, preset=preset_name, option=option_name): + try: + option = world_type.options_dataclass.type_hints[option_name].from_any(option_value) + supported_types = [Choice, Toggle, Range, NamedRange] + if not any([issubclass(option.__class__, t) for t in supported_types]): + self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' " + f"is not a supported type for webhost. " + f"Supported types: {', '.join([t.__name__ for t in supported_types])}") + except AssertionError as ex: + self.fail(f"Option '{option_name}': '{option_value}' in preset '{preset_name}' for game " + f"'{game_name}' is not valid. Error: {ex}") + except KeyError as ex: + self.fail(f"Option '{option_name}' in preset '{preset_name}' for game '{game_name}' is " + f"not a defined option. Error: {ex}") + + def test_option_preset_values_are_explicitly_defined(self): + """Test that option preset values are not a special flavor of 'random' or use from_text to resolve another + value. + """ + for game_name, world_type in AutoWorldRegister.world_types.items(): + presets = world_type.web.options_presets + for preset_name, preset in presets.items(): + for option_name, option_value in preset.items(): + with self.subTest(game=game_name, preset=preset_name, option=option_name): + # Check for non-standard random values. + self.assertFalse( + str(option_value).startswith("random-"), + f"'{option_name}': '{option_value}' in preset '{preset_name}' for game '{game_name}' " + f"is not supported for webhost. Special random values are not supported for presets." + ) + + option = world_type.options_dataclass.type_hints[option_name].from_any(option_value) + + # Check for from_text resolving to a different value. ("random" is allowed though.) + if option_value != "random" and isinstance(option_value, str): + # Allow special named values for NamedRange option presets. + if isinstance(option, NamedRange): + self.assertTrue( + option_value in option.special_range_names, + f"Invalid preset '{option_name}': '{option_value}' in preset '{preset_name}' " + f"for game '{game_name}'. Expected {option.special_range_names.keys()} or " + f"{option.range_start}-{option.range_end}." + ) + else: + self.assertTrue( + option.name_lookup.get(option.value, None) == option_value, + f"'{option_name}': '{option_value}' in preset '{preset_name}' for game " + f"'{game_name}' is not supported for webhost. Values must not be resolved to a " + f"different option via option.from_text (or an alias)." + ) diff --git a/test/worlds/__init__.py b/test/worlds/__init__.py index d1817cc674..cf396111bf 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 217269aa99..f56c39f690 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -3,12 +3,15 @@ from __future__ import annotations import hashlib import logging import pathlib +import re import sys -from typing import Any, Callable, ClassVar, Dict, FrozenSet, List, Optional, Set, TYPE_CHECKING, TextIO, Tuple, Type, \ +import time +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 @@ -16,6 +19,8 @@ if TYPE_CHECKING: from . import GamesPackage from settings import Group +perf_logger = logging.getLogger("performance") + class AutoWorldRegister(type): world_types: Dict[str, Type[World]] = {} @@ -47,11 +52,17 @@ class AutoWorldRegister(type): dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set in dct.get("item_name_groups", {}).items()} dct["item_name_groups"]["Everything"] = dct["item_names"] + dct["item_descriptions"] = {name: _normalize_description(description) for name, description + in dct.get("item_descriptions", {}).items()} + dct["item_descriptions"]["Everything"] = "All items in the entire game." dct["location_names"] = frozenset(dct["location_name_to_id"]) dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set in dct.get("location_name_groups", {}).items()} dct["location_name_groups"]["Everywhere"] = dct["location_names"] dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {}))) + dct["location_descriptions"] = {name: _normalize_description(description) for name, description + in dct.get("location_descriptions", {}).items()} + dct["location_descriptions"]["Everywhere"] = "All locations in the entire game." # move away from get_required_client_version function if "game" in dct: @@ -63,6 +74,16 @@ class AutoWorldRegister(type): 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: + # TODO - switch to deprecate after a version + if __debug__: + from warnings import warn + warn("Assigning options through option_definitions is now deprecated. Use options_dataclass instead.") + 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: @@ -96,10 +117,24 @@ class AutoLogicRegister(type): return new_class +def _timed_call(method: Callable[..., Any], *args: Any, + multiworld: Optional["MultiWorld"] = None, player: Optional[int] = None) -> Any: + start = time.perf_counter() + ret = method(*args) + taken = time.perf_counter() - start + if taken > 1.0: + if player and multiworld: + perf_logger.info(f"Took {taken:.4f} seconds in {method.__qualname__} for player {player}, " + f"named {multiworld.player_name[player]}.") + else: + perf_logger.info(f"Took {taken:.4f} seconds in {method.__qualname__}.") + return ret + + def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any: method = getattr(multiworld.worlds[player], method_name) try: - ret = method(*args) + ret = _timed_call(method, *args, multiworld=multiworld, player=player) except Exception as e: message = f"Exception in {method} for player {player}, named {multiworld.player_name[player]}." if sys.version_info >= (3, 11, 0): @@ -125,24 +160,21 @@ def call_all(multiworld: "MultiWorld", method_name: str, *args: Any) -> None: f"Duplicate item reference of \"{item.name}\" in \"{multiworld.worlds[player].game}\" " f"of player \"{multiworld.player_name[player]}\". Please make a copy instead.") - for world_type in sorted(world_types, key=lambda world: world.__name__): - stage_callable = getattr(world_type, f"stage_{method_name}", None) - if stage_callable: - stage_callable(multiworld, *args) + call_stage(multiworld, method_name, *args) def call_stage(multiworld: "MultiWorld", method_name: str, *args: Any) -> None: world_types = {multiworld.worlds[player].__class__ for player in multiworld.player_ids} - for world_type in world_types: + for world_type in sorted(world_types, key=lambda world: world.__name__): stage_callable = getattr(world_type, f"stage_{method_name}", None) if stage_callable: - stage_callable(multiworld, *args) + _timed_call(stage_callable, multiworld, *args) class WebWorld: """Webhost integration""" - settings_page: Union[bool, str] = True + options_page: Union[bool, str] = True """display a settings page. Can be a link to a specific page or external tool.""" game_info_languages: List[str] = ['en'] @@ -158,13 +190,19 @@ class WebWorld: bug_report_page: Optional[str] """display a link to a bug report page, most likely a link to a GitHub issue page.""" + options_presets: Dict[str, Dict[str, Any]] = {} + """A dictionary containing a collection of developer-defined game option presets.""" + 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 @@ -181,9 +219,23 @@ class World(metaclass=AutoWorldRegister): item_name_groups: ClassVar[Dict[str, Set[str]]] = {} """maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}""" + item_descriptions: ClassVar[Dict[str, str]] = {} + """An optional map from item names (or item group names) to brief descriptions for users. + + Individual newlines and indentation will be collapsed into spaces before these descriptions are + displayed. This may cover only a subset of items. + """ + location_name_groups: ClassVar[Dict[str, Set[str]]] = {} """maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}""" + location_descriptions: ClassVar[Dict[str, str]] = {} + """An optional map from location names (or location group names) to brief descriptions for users. + + Individual newlines and indentation will be collapsed into spaces before these descriptions are + displayed. This may cover only a subset of locations. + """ + data_version: ClassVar[int] = 0 """ Increment this every time something in your world's names/id mappings changes. @@ -358,6 +410,19 @@ class World(metaclass=AutoWorldRegister): 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. @@ -377,16 +442,16 @@ class World(metaclass=AutoWorldRegister): def collect(self, state: "CollectionState", item: "Item") -> bool: name = self.collect_item(state, item) if name: - state.prog_items[name, self.player] += 1 + state.prog_items[self.player][name] += 1 return True return False def remove(self, state: "CollectionState", item: "Item") -> bool: name = self.collect_item(state, item, True) if name: - state.prog_items[name, self.player] -= 1 - if state.prog_items[name, self.player] < 1: - del (state.prog_items[name, self.player]) + state.prog_items[self.player][name] -= 1 + if state.prog_items[self.player][name] < 1: + del (state.prog_items[self.player][name]) return True return False @@ -425,3 +490,17 @@ def data_package_checksum(data: "GamesPackage") -> str: assert sorted(data) == list(data), "Data not ordered" from NetUtils import encode return hashlib.sha1(encode(data).encode()).hexdigest() + + +def _normalize_description(description): + """Normalizes a description in item_descriptions or location_descriptions. + + This allows authors to write descritions with nice indentation and line lengths in their world + definitions without having it affect the rendered format. + """ + # First, collapse the whitespace around newlines and the ends of the description. + description = re.sub(r' *\n *', '\n', description.strip()) + # Next, condense individual newlines into spaces. + description = re.sub(r'(? None: manifest = self.get_manifest() diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index c3ae2b0495..03c89b75ff 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -101,8 +101,6 @@ components: List[Component] = [ Component('OoT Adjuster', 'OoTAdjuster'), # FF1 Component('FF1 Client', 'FF1Client'), - # Pokémon - Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')), # TLoZ Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')), # ChecksFinder @@ -114,8 +112,6 @@ components: List[Component] = [ # Zillion Component('Zillion Client', 'ZillionClient', file_identifier=SuffixIdentifier('.apzl')), - # Kingdom Hearts 2 - Component('KH2 Client', "KH2Client"), #MegaMan Battle Network 3 Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')) diff --git a/worlds/__init__.py b/worlds/__init__.py index c6208fa9a1..66c91639b9 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,53 +1,51 @@ import importlib import os import sys -import typing import warnings import zipimport +from typing import Dict, List, NamedTuple, TypedDict -folder = os.path.dirname(__file__) +from Utils import local_path, user_path + +local_folder = os.path.dirname(__file__) +user_folder = user_path("worlds") if user_path() != local_path() else None __all__ = { - "lookup_any_item_id_to_name", - "lookup_any_location_id_to_name", "network_data_package", "AutoWorldRegister", "world_sources", - "folder", + "local_folder", + "user_folder", + "GamesPackage", + "DataPackage", } -if typing.TYPE_CHECKING: - from .AutoWorld import World - -class GamesData(typing.TypedDict): - item_name_groups: typing.Dict[str, typing.List[str]] - item_name_to_id: typing.Dict[str, int] - location_name_groups: typing.Dict[str, typing.List[str]] - location_name_to_id: typing.Dict[str, int] - version: int - - -class GamesPackage(GamesData, total=False): +class GamesPackage(TypedDict, total=False): + item_name_groups: Dict[str, List[str]] + item_name_to_id: Dict[str, int] + location_name_groups: Dict[str, List[str]] + location_name_to_id: Dict[str, int] checksum: str + version: int # TODO: Remove support after per game data packages API change. -class DataPackage(typing.TypedDict): - games: typing.Dict[str, GamesPackage] +class DataPackage(TypedDict): + games: Dict[str, GamesPackage] -class WorldSource(typing.NamedTuple): +class WorldSource(NamedTuple): path: str # typically relative path from this module is_zip: bool = False relative: bool = True # relative to regular world import folder - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" @property def resolved_path(self) -> str: if self.relative: - return os.path.join(folder, self.path) + return os.path.join(local_folder, self.path) return self.path def load(self) -> bool: @@ -56,6 +54,7 @@ class WorldSource(typing.NamedTuple): importer = zipimport.zipimporter(self.resolved_path) if hasattr(importer, "find_spec"): # new in Python 3.10 spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0]) + assert spec, f"{self.path} is not a loadable module" mod = importlib.util.module_from_spec(spec) else: # TODO: remove with 3.8 support mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0]) @@ -72,7 +71,7 @@ class WorldSource(typing.NamedTuple): importlib.import_module(f".{self.path}", "worlds") return True - except Exception as e: + except Exception: # A single world failing can still mean enough is working for the user, log and carry on import traceback import io @@ -86,40 +85,26 @@ class WorldSource(typing.NamedTuple): # find potential world containers, currently folders and zip-importable .apworld's -world_sources: typing.List[WorldSource] = [] -file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly -for file in os.scandir(folder): - # prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "." - if not file.name.startswith(("_", ".")): - if file.is_dir(): - world_sources.append(WorldSource(file.name)) - elif file.is_file() and file.name.endswith(".apworld"): - world_sources.append(WorldSource(file.name, is_zip=True)) +world_sources: List[WorldSource] = [] +for folder in (folder for folder in (user_folder, local_folder) if folder): + relative = folder == local_folder + for entry in os.scandir(folder): + # prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "." + if not entry.name.startswith(("_", ".")): + file_name = entry.name if relative else os.path.join(folder, entry.name) + if entry.is_dir(): + world_sources.append(WorldSource(file_name, relative=relative)) + elif entry.is_file() and entry.name.endswith(".apworld"): + world_sources.append(WorldSource(file_name, is_zip=True, relative=relative)) # import all submodules to trigger AutoWorldRegister world_sources.sort() for world_source in world_sources: world_source.load() -lookup_any_item_id_to_name = {} -lookup_any_location_id_to_name = {} -games: typing.Dict[str, GamesPackage] = {} - +# Build the data package for each game. from .AutoWorld import AutoWorldRegister -# Build the data package for each game. -for world_name, world in AutoWorldRegister.world_types.items(): - games[world_name] = world.get_data_package_data() - lookup_any_item_id_to_name.update(world.item_id_to_name) - lookup_any_location_id_to_name.update(world.location_id_to_name) - network_data_package: DataPackage = { - "games": games, + "games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()}, } - -# Set entire datapackage to version 0 if any of them are set to 0 -if any(not world.data_version for world in AutoWorldRegister.world_types.values()): - import logging - - logging.warning(f"Datapackage is in custom mode. Custom Worlds: " - f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}") diff --git a/worlds/_bizhawk/__init__.py b/worlds/_bizhawk/__init__.py new file mode 100644 index 0000000000..94a9ce1ddf --- /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 sys +import typing + + +BIZHAWK_SOCKET_PORT_RANGE_START = 43055 +BIZHAWK_SOCKET_PORT_RANGE_SIZE = 5 + + +class ConnectionStatus(enum.IntEnum): + NOT_CONNECTED = 1 + TENTATIVE = 2 + CONNECTED = 3 + + +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 + + +class BizHawkContext: + streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]] + connection_status: ConnectionStatus + _lock: asyncio.Lock + _port: typing.Optional[int] + + def __init__(self) -> None: + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + self._lock = asyncio.Lock() + self._port = None + + async def _send_message(self, message: str): + async with self._lock: + if self.streams is None: + raise NotConnectedError("You tried to send a request before a connection to BizHawk was made") + + try: + reader, writer = self.streams + writer.write(message.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() + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection closed") + + if self.connection_status == ConnectionStatus.TENTATIVE: + self.connection_status = ConnectionStatus.CONNECTED + + return res.decode("utf-8") + except asyncio.TimeoutError as exc: + writer.close() + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection timed out") from exc + except ConnectionResetError as exc: + writer.close() + self.streams = None + self.connection_status = ConnectionStatus.NOT_CONNECTED + raise RequestFailedError("Connection reset") from exc + + +async def connect(ctx: BizHawkContext) -> bool: + """Attempts to establish a connection with a connector script. Returns True if successful.""" + rotation_steps = 0 if ctx._port is None else ctx._port - BIZHAWK_SOCKET_PORT_RANGE_START + ports = [*range(BIZHAWK_SOCKET_PORT_RANGE_START, BIZHAWK_SOCKET_PORT_RANGE_START + BIZHAWK_SOCKET_PORT_RANGE_SIZE)] + ports = ports[rotation_steps:] + ports[:rotation_steps] + + for port in ports: + try: + ctx.streams = await asyncio.open_connection("127.0.0.1", port) + ctx.connection_status = ConnectionStatus.TENTATIVE + ctx._port = port + return True + except (TimeoutError, ConnectionRefusedError): + continue + + # No ports worked + 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: + return int(await ctx._send_message("VERSION")) + + +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.""" + responses = json.loads(await ctx._send_message(json.dumps(req_list))) + errors: typing.List[ConnectorError] = [] + + for response in responses: + if response["type"] == "ERROR": + errors.append(ConnectorError(response["err"])) + + if errors: + if sys.version_info >= (3, 11, 0): + raise ExceptionGroup("Connector script returned errors", errors) # noqa + else: + raise errors[0] + + return responses + + +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 {item['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 {item['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 0000000000..32a6e3704e --- /dev/null +++ b/worlds/_bizhawk/client.py @@ -0,0 +1,103 @@ +""" +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 + + +def launch_client(*args) -> None: + from .context import launch + launch_subprocess(launch, name="BizHawkClient") + +component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, + file_identifier=SuffixIdentifier()) +components.append(component) + + +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) + + # Register handler + 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() + + # Update launcher component's suffixes + if "patch_suffix" in namespace: + if namespace["patch_suffix"] is not None: + existing_identifier: SuffixIdentifier = component.file_identifier + new_suffixes = [*existing_identifier.suffixes] + + if type(namespace["patch_suffix"]) is str: + new_suffixes.append(namespace["patch_suffix"]) + else: + new_suffixes.extend(namespace["patch_suffix"]) + + component.file_identifier = SuffixIdentifier(*new_suffixes) + + 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(s) that the game this client is for runs on""" + + game: ClassVar[str] + """The game this client is for""" + + patch_suffix: ClassVar[Optional[Union[str, Tuple[str, ...]]]] + """The file extension(s) this client is meant to open and patch (e.g. ".apz3")""" + + @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 diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py new file mode 100644 index 0000000000..4ee6e24f59 --- /dev/null +++ b/worlds/_bizhawk/context.py @@ -0,0 +1,272 @@ +""" +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 enum +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, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \ + get_script_version, get_system, ping +from .client import BizHawkClient, AutoBizHawkClientRegister + + +EXPECTED_SCRIPT_VERSION = 1 + + +class AuthStatus(enum.IntEnum): + NOT_AUTHENTICATED = 0 + NEED_INFO = 1 + PENDING = 2 + AUTHENTICATED = 3 + + +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 + auth_status: AuthStatus + password_requested: bool + 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.auth_status = AuthStatus.NOT_AUTHENTICATED + self.password_requested = False + 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) + self.auth_status = AuthStatus.AUTHENTICATED + + if self.client_handler is not None: + self.client_handler.on_package(self, cmd, args) + + async def server_auth(self, password_requested: bool = False): + self.password_requested = password_requested + + if self.bizhawk_ctx.connection_status != ConnectionStatus.CONNECTED: + logger.info("Awaiting connection to BizHawk before authenticating") + return + + if self.client_handler is None: + return + + # Ask handler to set auth + if self.auth is None: + self.auth_status = AuthStatus.NEED_INFO + await self.client_handler.set_auth(self) + + # Handler didn't set auth, ask user for slot name + if self.auth is None: + await self.get_username() + + if password_requested and not self.password: + self.auth_status = AuthStatus.NEED_INFO + await super(BizHawkClientContext, self).server_auth(password_requested) + + await self.send_connect() + self.auth_status = AuthStatus.PENDING + + async def disconnect(self, allow_autoreconnect: bool = False): + self.auth_status = AuthStatus.NOT_AUTHENTICATED + await super().disconnect(allow_autoreconnect) + + +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 + + # Since a call to `connect` can take a while to return, this will cancel connecting + # if the user has decided to close the client. + connect_task = asyncio.create_task(connect(ctx.bizhawk_ctx), name="BizHawkConnect") + exit_task = asyncio.create_task(ctx.exit_event.wait(), name="ExitWait") + await asyncio.wait([connect_task, exit_task], return_when=asyncio.FIRST_COMPLETED) + + if exit_task.done(): + connect_task.cancel() + return + + if not connect_task.result(): + # Failed to connect + 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 and not ctx.server.socket.closed: + logger.info(f"ROM changed. Disconnecting from server.") + + ctx.auth = None + ctx.username = None + ctx.client_handler = None + await ctx.disconnect(False) + 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 + except NotConnectedError: + continue + + # Server auth + if ctx.server is not None and not ctx.server.socket.closed: + if ctx.auth_status == AuthStatus.NOT_AUTHENTICATED: + Utils.async_start(ctx.server_auth(ctx.password_requested)) + else: + ctx.auth_status = AuthStatus.NOT_AUTHENTICATED + + # Call the handler's game watcher + 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, + f"--lua={Utils.local_path('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/adventure/Rom.py b/worlds/adventure/Rom.py index 62c4019718..9f1ca3fe5e 100644 --- a/worlds/adventure/Rom.py +++ b/worlds/adventure/Rom.py @@ -6,9 +6,8 @@ from typing import Optional, Any import Utils from .Locations import AdventureLocation, LocationData -from Utils import OptionsType +from settings import get_settings from worlds.Files import APDeltaPatch, AutoPatchRegister, APContainer -from itertools import chain import bsdiff4 @@ -313,9 +312,8 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: def get_base_rom_path(file_name: str = "") -> str: - options: OptionsType = Utils.get_options() if not file_name: - file_name = options["adventure_options"]["rom_file"] + file_name = get_settings()["adventure_options"]["rom_file"] if not os.path.exists(file_name): file_name = Utils.user_path(file_name) return file_name diff --git a/worlds/alttp/Client.py b/worlds/alttp/Client.py index 7ac24fde9f..edc68473b9 100644 --- a/worlds/alttp/Client.py +++ b/worlds/alttp/Client.py @@ -107,7 +107,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), "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), @@ -520,7 +520,8 @@ class ALTTPSNIClient(SNIClient): gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): currently_dead = gamemode[0] in DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) + await ctx.handle_deathlink_state(currently_dead, + ctx.player_names[ctx.slot] + " ran out of hearts." if ctx.slot else "") gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index b789fd6db6..b456174f39 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -8,7 +8,7 @@ from Fill import fill_restrictive 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 create_dungeons(world: "ALTTPWorld"): 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 create_dungeons(world: "ALTTPWorld"): 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 create_dungeons(world: "ALTTPWorld"): '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,17 @@ 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, allow_excluded=True, + name="LttP Dungeon Items") dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A], diff --git a/worlds/alttp/EntranceShuffle.py b/worlds/alttp/EntranceShuffle.py index b7fe688431..07bb587eeb 100644 --- a/worlds/alttp/EntranceShuffle.py +++ b/worlds/alttp/EntranceShuffle.py @@ -3134,6 +3134,7 @@ mandatory_connections = [('Links House S&Q', 'Links House'), ('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 @@ mandatory_connections = [('Links House S&Q', 'Links House'), ('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 @@ mandatory_connections = [('Links House S&Q', 'Links House'), ('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 @@ inverted_mandatory_connections = [('Links House S&Q', 'Inverted Links House'), ('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 @@ inverted_mandatory_connections = [('Links House S&Q', 'Inverted Links House'), ('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 @@ inverted_mandatory_connections = [('Links House S&Q', 'Inverted Links House'), ('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 acec73bf33..f89eebec33 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 @@ -529,8 +477,6 @@ def create_inverted_regions(world, player): create_lw_region(world, player, 'Death Mountain Bunny Descent Area') ] - world.initialize_regions() - def mark_dark_world_regions(world, player): # cross world caves may have some sections marked as both in_light_world, and in_dark_work. diff --git a/worlds/alttp/ItemPool.py b/worlds/alttp/ItemPool.py index 56eb355837..1c3f3e44f7 100644 --- a/worlds/alttp/ItemPool.py +++ b/worlds/alttp/ItemPool.py @@ -12,6 +12,7 @@ from .EntranceShuffle import connect_entrance 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 @@ difficulties = { 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 @@ difficulties = { 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 @@ difficulties = { 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 @@ difficulties = { 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 @@ for diff in {'easy', 'normal', 'hard', 'expert'}: 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)) @@ -293,7 +293,6 @@ def generate_itempool(world): loc.access_rule = lambda state: has_triforce_pieces(state, player) region.locations.append(loc) - multiworld.clear_location_cache() multiworld.push_item(loc, ItemFactory('Triforce', player), False) loc.event = True @@ -374,11 +373,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 +417,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]) @@ -508,8 +534,6 @@ def set_up_take_anys(world, player): take_any.shop.add_inventory(0, 'Blue Potion', 0, 0) take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0, create_location=True) - world.initialize_regions() - def get_pool_core(world, player: int): shuffle = world.shuffle[player] @@ -639,14 +663,25 @@ def get_pool_core(world, player: int): 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] return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon, additional_pieces_to_place) @@ -799,7 +834,9 @@ def make_custom_item_pool(world, player): 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/Items.py b/worlds/alttp/Items.py index 40634de8da..18f96b2ddb 100644 --- a/worlds/alttp/Items.py +++ b/worlds/alttp/Items.py @@ -102,7 +102,7 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\ 'Red Pendant': ItemData(IC.progression, 'Crystal', (0x01, 0x32, 0x60, 0x00, 0x69, 0x03), None, None, None, None, None, None, "the red pendant"), 'Triforce': ItemData(IC.progression, None, 0x6A, '\n YOU WIN!', 'and the triforce', 'victorious kid', 'victory for sale', 'fungus for the win', 'greedy boy wins game again', 'the Triforce'), 'Power Star': ItemData(IC.progression, None, 0x6B, 'a small victory', 'and the power star', 'star-struck kid', 'star for sale', 'see stars with shroom', 'mario powers up again', 'a Power Star'), - 'Triforce Piece': ItemData(IC.progression, None, 0x6C, 'a small victory', 'and the thirdforce', 'triangular kid', 'triangle for sale', 'fungus for triangle', 'wise boy has triangle again', 'a Triforce Piece'), + 'Triforce Piece': ItemData(IC.progression_skip_balancing, None, 0x6C, 'a small victory', 'and the thirdforce', 'triangular kid', 'triangle for sale', 'fungus for triangle', 'wise boy has triangle again', 'a Triforce Piece'), 'Crystal 1': ItemData(IC.progression, 'Crystal', (0x02, 0x34, 0x64, 0x40, 0x7F, 0x06), None, None, None, None, None, None, "a blue crystal"), 'Crystal 2': ItemData(IC.progression, 'Crystal', (0x10, 0x34, 0x64, 0x40, 0x79, 0x06), None, None, None, None, None, None, "a blue crystal"), 'Crystal 3': ItemData(IC.progression, 'Crystal', (0x40, 0x34, 0x64, 0x40, 0x6C, 0x06), None, None, None, None, None, None, "a blue crystal"), diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index b4b0958ac2..a89a9adb83 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -101,6 +101,12 @@ class map_shuffle(DungeonItem): display_name = "Map Shuffle" +class key_drop_shuffle(Toggle): + """Shuffle keys found in pots and dropped from killed enemies, + respects the small key and big key shuffle options.""" + display_name = "Key Drop Shuffle" + + class Crystals(Range): range_start = 0 range_end = 7 @@ -432,6 +438,7 @@ alttp_options: typing.Dict[str, type(Option)] = { "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 9badbd8774..0cc8a3d6a7 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']), @@ -505,8 +382,6 @@ def create_regions(world, player): create_dw_region(world, player, 'Dark Death Mountain Bunny Descent Area') ] - world.initialize_regions() - def create_lw_region(world: MultiWorld, player: int, name: str, locations=None, exits=None): return _create_region(world, player, name, LTTPRegionType.LightWorld, 'Light World', locations, exits) @@ -533,8 +408,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 +466,39 @@ old_location_address_to_new_location_address = { 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 ed222b5f5d..b80cec578a 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -25,7 +25,7 @@ from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to 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) @@ -771,11 +783,12 @@ def get_nonnative_item_sprite(code: int) -> int: def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): local_random = world.per_slot_randoms[player] + local_world = world.worlds[player] # patch items - for location in world.get_locations(): - if location.player != player or location.address is None or location.shop_slot is not None: + for location in world.get_locations(player): + if location.address is None or location.shop_slot is not None: continue itemid = location.item.code if location.item is not None else 0x5A @@ -897,6 +910,29 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): 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) @@ -1155,12 +1191,8 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): ]) # set Fountain bottle exchange items - if world.difficulty[player] in ['hard', 'expert']: - rom.write_byte(0x348FF, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x48][local_random.randint(0, 5)]) - rom.write_byte(0x3493B, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x48][local_random.randint(0, 5)]) - else: - rom.write_byte(0x348FF, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x3D, 0x48][local_random.randint(0, 6)]) - rom.write_byte(0x3493B, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x3D, 0x48][local_random.randint(0, 6)]) + rom.write_byte(0x348FF, item_table[local_world.waterfall_fairy_bottle_fill].item_code) + rom.write_byte(0x3493B, item_table[local_world.pyramid_fairy_bottle_fill].item_code) # enable Fat Fairy Chests rom.write_bytes(0x1FC16, [0xB1, 0xC6, 0xF9, 0xC9, 0xC6, 0xF9]) @@ -1824,10 +1856,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, @@ -2212,7 +2244,7 @@ def write_strings(rom, world, player): tt['sign_north_of_links_house'] = '> Randomizer The telepathic tiles can have hints!' hint_locations = HintLocations.copy() local_random.shuffle(hint_locations) - all_entrances = [entrance for entrance in world.get_entrances() if entrance.player == player] + all_entrances = list(world.get_entrances(player)) local_random.shuffle(all_entrances) # First we take care of the one inconvenient dungeon in the appropriately simple shuffles. diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index ce4a941ead..8a04f87afa 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -136,7 +136,8 @@ def mirrorless_path_to_castle_courtyard(world, player): def set_defeat_dungeon_boss_rule(location): # Lambda required to defer evaluation of dungeon.boss since it will change later if boss shuffle is used - set_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state)) + add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state)) + def set_always_allow(spot, rule): spot.always_allow = rule @@ -197,8 +198,13 @@ def global_rules(world, player): # determines which S&Q locations are available - hide from paths since it isn't an in-game location for exit in world.get_region('Menu', player).exits: exit.hide_path = True - - set_rule(world.get_entrance('Old Man S&Q', player), lambda state: state.can_reach('Old Man', 'Location', player)) + try: + old_man_sq = world.get_entrance('Old Man S&Q', player) + except KeyError: + pass # it doesn't exist, should be dungeon-only unittests + else: + old_man = world.get_location("Old Man", player) + set_rule(old_man_sq, lambda state: old_man.can_reach(state)) set_rule(world.get_location('Sunken Treasure', player), lambda state: state.has('Open Floodgate', player)) set_rule(world.get_location('Dark Blacksmith Ruins', player), lambda state: state.has('Return Smith', player)) @@ -231,26 +237,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 +279,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 +300,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 +403,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 +427,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)) + 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))) - # 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)) + # 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)) - #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))) + # 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))) + 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 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)) + # 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 +485,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 +874,21 @@ def add_conditional_lamps(world, player): 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 +900,32 @@ def add_conditional_lamps(world, player): 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 +948,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 +983,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 +1006,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 +1018,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)) - 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) - 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)) + 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)) - # 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)) + 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 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, 6)) + + # 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 +1055,8 @@ def set_trock_key_rules(world, player): 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 +1065,12 @@ def set_trock_key_rules(world, player): 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: @@ -1408,16 +1532,16 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool): # Helper functions to determine if the moon pearl is required if inverted: def is_bunny(region): - return region.is_light_world + return region and region.is_light_world def is_link(region): - return region.is_dark_world + return region and region.is_dark_world else: def is_bunny(region): - return region.is_dark_world + return region and region.is_dark_world def is_link(region): - return region.is_light_world + return region and region.is_light_world def get_rule_to_add(region, location = None, connecting_entrance = None): # In OWG, a location can potentially be superbunny-mirror accessible or @@ -1485,21 +1609,20 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool): return options_to_access_rule(possible_options) # Add requirements for bunny-impassible caves if link is a bunny in them - for region in [world.get_region(name, player) for name in bunny_impassable_caves]: - + for region in (world.get_region(name, player) for name in bunny_impassable_caves): if not is_bunny(region): continue rule = get_rule_to_add(region) - for exit in region.exits: - add_rule(exit, rule) + for region_exit in region.exits: + add_rule(region_exit, rule) paradox_shop = world.get_region('Light World Death Mountain Shop', player) if is_bunny(paradox_shop): add_rule(paradox_shop.entrances[0], get_rule_to_add(paradox_shop)) # Add requirements for all locations that are actually in the dark world, except those available to the bunny, including dungeon revival - for entrance in world.get_entrances(): - if entrance.player == player and is_bunny(entrance.connected_region): + for entrance in world.get_entrances(player): + if is_bunny(entrance.connected_region): if world.logic[player] in ['minorglitches', 'owglitches', 'hybridglitches', 'nologic'] : if entrance.connected_region.type == LTTPRegionType.Dungeon: if entrance.parent_region.type != LTTPRegionType.Dungeon and entrance.connected_region.name in OverworldGlitchRules.get_invalid_bunny_revival_dungeons(): diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index f17eb1eadb..c0f2e2236e 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -348,7 +348,6 @@ def create_shops(world, player: int): loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player) loc.shop_slot_disabled = True shop.region.locations.append(loc) - world.clear_location_cache() class ShopData(NamedTuple): @@ -619,6 +618,4 @@ def create_dynamic_shop_locations(world, player): if shop.type == ShopType.TakeAny: loc.shop_slot_disabled = True shop.region.locations.append(loc) - world.clear_location_cache() - loc.shop_slot = i diff --git a/worlds/alttp/StateHelpers.py b/worlds/alttp/StateHelpers.py index 95e31e5ba3..38ce00ef45 100644 --- a/worlds/alttp/StateHelpers.py +++ b/worlds/alttp/StateHelpers.py @@ -31,7 +31,7 @@ def can_shoot_arrows(state: CollectionState, player: int) -> bool: def has_triforce_pieces(state: CollectionState, player: int) -> bool: count = state.multiworld.treasure_hunt_count[player] - return state.item_count('Triforce Piece', player) + state.item_count('Power Star', player) >= count + return state.count('Triforce Piece', player) + state.count('Power Star', player) >= count def has_crystals(state: CollectionState, count: int, player: int) -> bool: @@ -60,9 +60,9 @@ def has_hearts(state: CollectionState, player: int, count: int) -> int: def heart_count(state: CollectionState, player: int) -> int: # Warning: This only considers items that are marked as advancement items diff = state.multiworld.difficulty_requirements[player] - return min(state.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ - + state.item_count('Sanctuary Heart Container', player) \ - + min(state.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ + return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \ + + state.count('Sanctuary Heart Container', player) \ + + min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ + 3 # starting hearts diff --git a/worlds/alttp/UnderworldGlitchRules.py b/worlds/alttp/UnderworldGlitchRules.py index 11a95bf7cd..a6aefc7412 100644 --- a/worlds/alttp/UnderworldGlitchRules.py +++ b/worlds/alttp/UnderworldGlitchRules.py @@ -31,7 +31,7 @@ def fake_pearl_state(state, player): if state.has('Moon Pearl', player): return state fake_state = state.copy() - fake_state.prog_items['Moon Pearl', player] += 1 + fake_state.prog_items[player]['Moon Pearl'] += 1 return fake_state @@ -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 8815fae092..3f380d0037 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -15,7 +15,7 @@ from .ItemPool import generate_itempool, difficulties 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 @@ -195,7 +195,7 @@ class ALTTPWorld(World): "Ganons Tower": {"Ganons Tower - Bob's Torch", "Ganons Tower - Hope Room - Left", "Ganons Tower - Hope Room - Right", "Ganons Tower - Tile Room", "Ganons Tower - Compass Room - Top Left", "Ganons Tower - Compass Room - Top Right", - "Ganons Tower - Compass Room - Bottom Left", "Ganons Tower - Compass Room - Bottom Left", + "Ganons Tower - Compass Room - Bottom Left", "Ganons Tower - Compass Room - Bottom Right", "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 - Map Chest", "Ganons Tower - Firesnake Room", @@ -249,6 +249,8 @@ class ALTTPWorld(World): rom_name_available_event: threading.Event has_progressive_bows: bool dungeons: typing.Dict[str, Dungeon] + waterfall_fairy_bottle_fill: str + pyramid_fairy_bottle_fill: str def __init__(self, *args, **kwargs): self.dungeon_local_item_names = set() @@ -256,6 +258,8 @@ class ALTTPWorld(World): self.rom_name_available_event = threading.Event() self.has_progressive_bows = False self.dungeons = {} + self.waterfall_fairy_bottle_fill = "Bottle" + self.pyramid_fairy_bottle_fill = "Bottle" super(ALTTPWorld, self).__init__(*args, **kwargs) @classmethod @@ -273,50 +277,67 @@ class ALTTPWorld(World): def generate_early(self): player = self.player - world = self.multiworld + multiworld = self.multiworld - 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 \ - and world.smallkey_shuffle[player] != smallkey_shuffle.option_start_with: - self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1 + # fairy bottle fills + bottle_options = [ + "Bottle (Red Potion)", "Bottle (Green Potion)", "Bottle (Blue Potion)", + "Bottle (Bee)", "Bottle (Good Bee)" + ] + if multiworld.difficulty[player] not in ["hard", "expert"]: + bottle_options.append("Bottle (Fairy)") + self.waterfall_fairy_bottle_fill = self.random.choice(bottle_options) + self.pyramid_fairy_bottle_fill = self.random.choice(bottle_options) + + if multiworld.mode[player] == 'standard': + if multiworld.smallkey_shuffle[player]: + if (multiworld.smallkey_shuffle[player] not in + (smallkey_shuffle.option_universal, smallkey_shuffle.option_own_dungeons, + smallkey_shuffle.option_start_with)): + self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1 + self.multiworld.local_items[self.player].value.add("Small Key (Hyrule Castle)") + self.multiworld.non_local_items[self.player].value.discard("Small Key (Hyrule Castle)") + if multiworld.bigkey_shuffle[player]: + self.multiworld.local_items[self.player].value.add("Big Key (Hyrule Castle)") + self.multiworld.non_local_items[self.player].value.discard("Big Key (Hyrule Castle)") # system for sharing ER layouts - self.er_seed = str(world.random.randint(0, 2 ** 64)) + self.er_seed = str(multiworld.random.randint(0, 2 ** 64)) - if "-" in world.shuffle[player]: - shuffle, seed = world.shuffle[player].split("-", 1) - world.shuffle[player] = shuffle + if "-" in multiworld.shuffle[player]: + shuffle, seed = multiworld.shuffle[player].split("-", 1) + multiworld.shuffle[player] = shuffle if shuffle == "vanilla": self.er_seed = "vanilla" - elif seed.startswith("group-") or world.is_race: - self.er_seed = get_same_seed(world, ( - shuffle, seed, world.retro_caves[player], world.mode[player], world.logic[player])) + elif seed.startswith("group-") or multiworld.is_race: + self.er_seed = get_same_seed(multiworld, ( + shuffle, seed, multiworld.retro_caves[player], multiworld.mode[player], multiworld.logic[player])) else: # not a race or group seed, use set seed as is. self.er_seed = seed - elif world.shuffle[player] == "vanilla": + elif multiworld.shuffle[player] == "vanilla": self.er_seed = "vanilla" for dungeon_item in ["smallkey_shuffle", "bigkey_shuffle", "compass_shuffle", "map_shuffle"]: - option = getattr(world, dungeon_item)[player] + option = getattr(multiworld, dungeon_item)[player] if option == "own_world": - world.local_items[player].value |= self.item_name_groups[option.item_name_group] + multiworld.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] + multiworld.non_local_items[player].value |= self.item_name_groups[option.item_name_group] + if multiworld.mode[player] == "standard": + multiworld.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": self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group] - world.difficulty_requirements[player] = difficulties[world.difficulty[player]] + multiworld.difficulty_requirements[player] = difficulties[multiworld.difficulty[player]] # enforce pre-defined local items. - if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: - world.local_items[player].value.add('Triforce Piece') + if multiworld.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: + multiworld.local_items[player].value.add('Triforce Piece') # Not possible to place crystals outside boss prizes yet (might as well make it consistent with pendants too). - world.non_local_items[player].value -= item_name_groups['Pendants'] - world.non_local_items[player].value -= item_name_groups['Crystals'] + multiworld.non_local_items[player].value -= item_name_groups['Pendants'] + multiworld.non_local_items[player].value -= item_name_groups['Crystals'] create_dungeons = create_dungeons @@ -362,7 +383,6 @@ class ALTTPWorld(World): world.register_indirect_condition(world.get_region(region_name, player), world.get_entrance(entrance_name, player)) - def collect_item(self, state: CollectionState, item: Item, remove=False): item_name = item.name if item_name.startswith('Progressive '): @@ -468,7 +488,8 @@ class ALTTPWorld(World): prizepool = unplaced_prizes.copy() prize_locs = empty_crystal_locations.copy() world.random.shuffle(prize_locs) - fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True) + fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True, + name="LttP Dungeon Prizes") except FillError as e: lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e, attempts - attempt) @@ -478,12 +499,17 @@ class ALTTPWorld(World): 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) @@ -578,27 +604,26 @@ class ALTTPWorld(World): for player in checks_in_area: checks_in_area[player]["Total"] = 0 - - for location in multiworld.get_locations(): - if location.game == cls.game and type(location.address) is int: - main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance) - if location.parent_region.dungeon: - dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', - 'Inverted Ganons Tower': 'Ganons Tower'} \ - .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) - checks_in_area[location.player][dungeonname].append(location.address) - elif location.parent_region.type == LTTPRegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif location.parent_region.type == LTTPRegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - elif main_entrance.parent_region.type == LTTPRegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - else: - assert False, "Unknown Location area." - # TODO: remove Total as it's duplicated data and breaks consistent typing - checks_in_area[location.player]["Total"] += 1 + for location in multiworld.get_locations(player): + if location.game == cls.game and type(location.address) is int: + main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance) + if location.parent_region.dungeon: + dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', + 'Inverted Ganons Tower': 'Ganons Tower'} \ + .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) + checks_in_area[location.player][dungeonname].append(location.address) + elif location.parent_region.type == LTTPRegionType.LightWorld: + checks_in_area[location.player]["Light World"].append(location.address) + elif location.parent_region.type == LTTPRegionType.DarkWorld: + checks_in_area[location.player]["Dark World"].append(location.address) + elif main_entrance.parent_region.type == LTTPRegionType.LightWorld: + checks_in_area[location.player]["Light World"].append(location.address) + elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld: + checks_in_area[location.player]["Dark World"].append(location.address) + else: + assert False, "Unknown Location area." + # TODO: remove Total as it's duplicated data and breaks consistent typing + checks_in_area[location.player]["Total"] += 1 multidata["checks_in_area"].update(checks_in_area) @@ -618,7 +643,6 @@ class ALTTPWorld(World): @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"}: @@ -687,13 +711,18 @@ class ALTTPWorld(World): spoiler_handle.write('Prize shuffle %s\n' % self.multiworld.shuffle_prizes[self.player]) def write_spoiler(self, spoiler_handle: typing.TextIO) -> None: + player_name = self.multiworld.get_player_name(self.player) spoiler_handle.write("\n\nMedallions:\n") - spoiler_handle.write(f"\nMisery Mire ({self.multiworld.get_player_name(self.player)}):" + spoiler_handle.write(f"\nMisery Mire ({player_name}):" f" {self.multiworld.required_medallions[self.player][0]}") spoiler_handle.write( - f"\nTurtle Rock ({self.multiworld.get_player_name(self.player)}):" + f"\nTurtle Rock ({player_name}):" f" {self.multiworld.required_medallions[self.player][1]}") - + spoiler_handle.write("\n\nFairy Fountain Bottle Fill:\n") + spoiler_handle.write(f"\nPyramid Fairy ({player_name}):" + f" {self.pyramid_fairy_bottle_fill}") + spoiler_handle.write(f"\nWaterfall Fairy ({player_name}):" + f" {self.waterfall_fairy_bottle_fill}") if self.multiworld.boss_shuffle[self.player] != "none": def create_boss_map() -> typing.Dict: boss_map = { @@ -792,7 +821,7 @@ class ALTTPWorld(World): 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 +832,11 @@ class ALTTPWorld(World): '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", {}) @@ -824,4 +853,4 @@ class ALttPLogic(LogicMixin): return True if self.multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal: return can_buy_unlimited(self, 'Small Key (Universal)', player) - return self.prog_items[item, player] >= count + return self.prog_items[player][item] >= count diff --git a/worlds/alttp/docs/multiworld_de.md b/worlds/alttp/docs/multiworld_de.md index 38009fb58e..8ccd1a87a6 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 8576318bb9..37aeda2a63 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 329ca65375..078a270f08 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 e69de29bb2..5baaa7e88e 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 6d0e1085f5..94e7854858 100644 --- a/worlds/alttp/test/dungeons/TestAgahnimsTower.py +++ b/worlds/alttp/test/dungeons/TestAgahnimsTower.py @@ -16,6 +16,18 @@ class TestAgahnimsTower(TestDungeon): ["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 8423e681cf..2d19513911 100644 --- a/worlds/alttp/test/dungeons/TestDesertPalace.py +++ b/worlds/alttp/test/dungeons/TestDesertPalace.py @@ -18,12 +18,27 @@ class TestDesertPalace(TestDungeon): ["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 @@ class TestDesertPalace(TestDungeon): ["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 73ece15417..8ca2791dcf 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 BaseClasses import CollectionState, ItemClassification from worlds.alttp.Dungeons import 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 @@ class TestDungeon(unittest.TestCase): 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 0497a1132e..35c1b99283 100644 --- a/worlds/alttp/test/dungeons/TestEasternPalace.py +++ b/worlds/alttp/test/dungeons/TestEasternPalace.py @@ -18,7 +18,8 @@ class TestEasternPalace(TestDungeon): ["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 f81509273f..d22dc92b36 100644 --- a/worlds/alttp/test/dungeons/TestGanonsTower.py +++ b/worlds/alttp/test/dungeons/TestGanonsTower.py @@ -33,46 +33,50 @@ class TestGanonsTower(TestDungeon): ["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 @@ class TestGanonsTower(TestDungeon): ["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 @@ class TestGanonsTower(TestDungeon): ["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 @@ class TestGanonsTower(TestDungeon): ["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 3c075fe5ea..edc9f1fbae 100644 --- a/worlds/alttp/test/dungeons/TestIcePalace.py +++ b/worlds/alttp/test/dungeons/TestIcePalace.py @@ -72,8 +72,9 @@ class TestIcePalace(TestDungeon): ["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 2dab840cf4..7f97c4d2f8 100644 --- a/worlds/alttp/test/dungeons/TestSkullWoods.py +++ b/worlds/alttp/test/dungeons/TestSkullWoods.py @@ -26,18 +26,18 @@ class TestSkullWoods(TestDungeon): ["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 @@ class TestSkullWoods(TestDungeon): ["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 @@ class TestSkullWoods(TestDungeon): ["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 @@ class TestSkullWoods(TestDungeon): ["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 a7e20bc520..01f1570a25 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 @@ class TestThievesTown(TestDungeon): ["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 @@ class TestThievesTown(TestDungeon): ["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 ad7458202e..f5608ba07b 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.Regions import mark_light_world_regions 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 89c5d78603..d9eacb5ad9 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 533e3c650f..fe8979c1ef 100644 --- a/worlds/alttp/test/inverted/TestInvertedTurtleRock.py +++ b/worlds/alttp/test/inverted/TestInvertedTurtleRock.py @@ -18,10 +18,9 @@ class TestInvertedTurtleRock(TestInverted): ["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 @@ class TestInvertedTurtleRock(TestInverted): ["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 @@ class TestInvertedTurtleRock(TestInverted): ["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 @@ class TestInvertedTurtleRock(TestInverted): ["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 @@ class TestInvertedTurtleRock(TestInverted): ["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 @@ class TestInvertedTurtleRock(TestInverted): [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 72049e1774..33e5822981 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 a25d89a6f4..d7b5c9f797 100644 --- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py +++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py @@ -20,8 +20,8 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["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 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["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 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["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 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["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 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["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 @@ class TestInvertedTurtleRock(TestInvertedMinor): [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 77a551db6f..a4e84fce9b 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 fdf626fe9d..d5cfd3095b 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 284b489b16..4f87896967 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 c0888aa32f..37b0b6ccb8 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 e338410df2..3c983e9850 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/archipidle/Rules.py b/worlds/archipidle/Rules.py index cdd48e7604..3bf4bad475 100644 --- a/worlds/archipidle/Rules.py +++ b/worlds/archipidle/Rules.py @@ -5,12 +5,7 @@ from ..generic.Rules import set_rule class ArchipIDLELogic(LogicMixin): def _archipidle_location_is_accessible(self, player_id, items_required): - items_received = 0 - for item in self.prog_items: - if item[1] == player_id: - items_received += 1 - - return items_received >= items_required + return sum(self.prog_items[player_id].values()) >= items_required def set_rules(world: MultiWorld, player: int): diff --git a/worlds/bk_sudoku/__init__.py b/worlds/bk_sudoku/__init__.py index f914baf066..36d863bb44 100644 --- a/worlds/bk_sudoku/__init__.py +++ b/worlds/bk_sudoku/__init__.py @@ -5,7 +5,7 @@ from ..AutoWorld import WebWorld, World class Bk_SudokuWebWorld(WebWorld): - settings_page = "games/Sudoku/info/en" + options_page = "games/Sudoku/info/en" theme = 'partyTime' tutorials = [ Tutorial( diff --git a/worlds/blasphemous/Options.py b/worlds/blasphemous/Options.py index ea304d22ed..127a1dc776 100644 --- a/worlds/blasphemous/Options.py +++ b/worlds/blasphemous/Options.py @@ -67,6 +67,7 @@ class StartingLocation(ChoiceIsRandom): class Ending(Choice): """Choose which ending is required to complete the game. + Talking to Tirso in Albero will tell you the selected ending for the current game. Ending A: Collect all thorn upgrades. Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation.""" display_name = "Ending" diff --git a/worlds/blasphemous/Rules.py b/worlds/blasphemous/Rules.py index 248ff645bc..5d88292131 100644 --- a/worlds/blasphemous/Rules.py +++ b/worlds/blasphemous/Rules.py @@ -578,11 +578,12 @@ def rules(blasphemousworld): or state.has("Purified Hand of the Nun", player) or state.has("D01Z02S03[NW]", player) and ( - can_cross_gap(state, logic, player, 1) + can_cross_gap(state, logic, player, 2) or state.has("Lorquiana", player) or aubade(state, player) or state.has("Cantina of the Blue Rose", player) or charge_beam(state, player) + or state.has("Ranged Skill", player) ) )) set_rule(world.get_location("Albero: Lvdovico's 1st reward", player), @@ -702,10 +703,11 @@ def rules(blasphemousworld): # Items set_rule(world.get_location("WotBC: Cliffside Child of Moonlight", player), lambda state: ( - can_cross_gap(state, logic, player, 1) + can_cross_gap(state, logic, player, 2) or aubade(state, player) or charge_beam(state, player) - or state.has_any({"Lorquiana", "Cante Jondo of the Three Sisters", "Cantina of the Blue Rose", "Cloistered Ruby"}, player) + or state.has_any({"Lorquiana", "Cante Jondo of the Three Sisters", "Cantina of the Blue Rose", \ + "Cloistered Ruby", "Ranged Skill"}, player) or precise_skips_allowed(logic) )) # Doors @@ -4193,8 +4195,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/blasphemous/docs/en_Blasphemous.md b/worlds/blasphemous/docs/en_Blasphemous.md index 15223213ac..1ff7f5a903 100644 --- a/worlds/blasphemous/docs/en_Blasphemous.md +++ b/worlds/blasphemous/docs/en_Blasphemous.md @@ -19,6 +19,7 @@ In addition, there are other changes to the game that make it better optimized f - The Apodictic Heart of Mea Culpa can be unequipped. - Dying with the Immaculate Bead is unnecessary, it is automatically upgraded to the Weight of True Guilt. - If the option is enabled, the 34 corpses in game will have their messages changed to give hints about certain items and locations. The Shroud of Dreamt Sins is not required to hear them. +- Talking to Tirso in Albero will tell you the selected ending for the current game. ## What has been changed about the side quests? diff --git a/worlds/bumpstik/Regions.py b/worlds/bumpstik/Regions.py index 247d6d61a3..6cddde882a 100644 --- a/worlds/bumpstik/Regions.py +++ b/worlds/bumpstik/Regions.py @@ -23,13 +23,13 @@ def create_regions(world: MultiWorld, player: int): entrance_map = { "Level 1": lambda state: - state.has("Booster Bumper", player, 2) and state.has("Treasure Bumper", player, 9), + state.has("Booster Bumper", player, 1) and state.has("Treasure Bumper", player, 8), "Level 2": lambda state: - state.has("Booster Bumper", player, 3) and state.has("Treasure Bumper", player, 17), + state.has("Booster Bumper", player, 2) and state.has("Treasure Bumper", player, 16), "Level 3": lambda state: - state.has("Booster Bumper", player, 4) and state.has("Treasure Bumper", player, 25), + state.has("Booster Bumper", player, 3) and state.has("Treasure Bumper", player, 24), "Level 4": lambda state: - state.has("Booster Bumper", player, 5) and state.has("Treasure Bumper", player, 33) + state.has("Booster Bumper", player, 5) and state.has("Treasure Bumper", player, 32) } for x, region_name in enumerate(region_map): diff --git a/worlds/bumpstik/__init__.py b/worlds/bumpstik/__init__.py index 9eeb3325e3..c4e65d07b6 100644 --- a/worlds/bumpstik/__init__.py +++ b/worlds/bumpstik/__init__.py @@ -108,7 +108,7 @@ class BumpStikWorld(World): item_pool += self._create_item_in_quantities( name, frequencies[i]) - item_delta = len(location_table) - len(item_pool) - 1 + item_delta = len(location_table) - len(item_pool) if item_delta > 0: item_pool += self._create_item_in_quantities( "Score Bonus", item_delta) @@ -116,13 +116,16 @@ class BumpStikWorld(World): self.multiworld.itempool += item_pool def set_rules(self): - forbid_item(self.multiworld.get_location("Bonus Booster 5", self.player), - "Booster Bumper", self.player) - - def generate_basic(self): - self.multiworld.get_location("Level 5 - Cleared all Hazards", self.player).place_locked_item( - self.create_item(self.get_filler_item_name())) - + for x in range(1, 32): + self.multiworld.get_location(f"Treasure Bumper {x + 1}", self.player).access_rule = \ + lambda state, x = x: state.has("Treasure Bumper", self.player, x) + for x in range(1, 5): + self.multiworld.get_location(f"Bonus Booster {x + 1}", self.player).access_rule = \ + lambda state, x = x: state.has("Booster Bumper", self.player, x) + self.multiworld.get_location("Level 5 - Cleared all Hazards", self.player).access_rule = \ + lambda state: state.has("Hazard Bumper", self.player, 25) + self.multiworld.completion_condition[self.player] = \ lambda state: state.has("Booster Bumper", self.player, 5) and \ state.has("Treasure Bumper", self.player, 32) + diff --git a/worlds/bumpstik/test/TestLogic.py b/worlds/bumpstik/test/TestLogic.py new file mode 100644 index 0000000000..e374b7b1e9 --- /dev/null +++ b/worlds/bumpstik/test/TestLogic.py @@ -0,0 +1,39 @@ +from . import BumpStikTestBase + + +class TestRuleLogic(BumpStikTestBase): + def testLogic(self): + for x in range(1, 33): + if x == 32: + self.assertFalse(self.can_reach_location("Level 5 - Cleared all Hazards")) + + self.collect(self.get_item_by_name("Treasure Bumper")) + if x % 8 == 0: + bb_count = round(x / 8) + + if bb_count < 4: + self.assertFalse(self.can_reach_location(f"Treasure Bumper {x + 1}")) + elif bb_count == 4: + bb_count += 1 + + for y in range(self.count("Booster Bumper"), bb_count): + self.assertTrue(self.can_reach_location(f"Bonus Booster {y + 1}"), + f"BB {y + 1} check not reachable with {self.count('Booster Bumper')} BBs") + if y < 4: + self.assertFalse(self.can_reach_location(f"Bonus Booster {y + 2}"), + f"BB {y + 2} check reachable with {self.count('Treasure Bumper')} TBs") + self.collect(self.get_item_by_name("Booster Bumper")) + + if x < 31: + self.assertFalse(self.can_reach_location(f"Treasure Bumper {x + 2}")) + elif x == 31: + self.assertFalse(self.can_reach_location("Level 5 - 50,000+ Total Points")) + + if x < 32: + self.assertTrue(self.can_reach_location(f"Treasure Bumper {x + 1}"), + f"TB {x + 1} check not reachable with {self.count('Treasure Bumper')} TBs") + elif x == 32: + self.assertTrue(self.can_reach_location("Level 5 - 50,000+ Total Points")) + self.assertFalse(self.can_reach_location("Level 5 - Cleared all Hazards")) + self.collect(self.get_items_by_name("Hazard Bumper")) + self.assertTrue(self.can_reach_location("Level 5 - Cleared all Hazards")) diff --git a/worlds/bumpstik/test/__init__.py b/worlds/bumpstik/test/__init__.py new file mode 100644 index 0000000000..1199d7b8e5 --- /dev/null +++ b/worlds/bumpstik/test/__init__.py @@ -0,0 +1,5 @@ +from test.TestBase import WorldTestBase + + +class BumpStikTestBase(WorldTestBase): + game = "Bumper Stickers" diff --git a/worlds/checksfinder/Rules.py b/worlds/checksfinder/Rules.py index 4e12668798..38d7d77ad3 100644 --- a/worlds/checksfinder/Rules.py +++ b/worlds/checksfinder/Rules.py @@ -1,37 +1,34 @@ -from ..generic.Rules import set_rule, add_rule -from BaseClasses import MultiWorld -from ..AutoWorld import LogicMixin +from ..generic.Rules import set_rule +from BaseClasses import MultiWorld, CollectionState -class ChecksFinderLogic(LogicMixin): - - def _has_total(self, player: int, total: int): - return (self.item_count('Map Width', player)+self.item_count('Map Height', player)+ - self.item_count('Map Bombs', player)) >= total +def _has_total(state: CollectionState, player: int, total: int): + return (state.count('Map Width', player) + state.count('Map Height', player) + + state.count('Map Bombs', player)) >= total # Sets rules on entrances and advancements that are always applied def set_rules(world: MultiWorld, player: int): - set_rule(world.get_location(("Tile 6"), player), lambda state: state._has_total(player, 1)) - set_rule(world.get_location(("Tile 7"), player), lambda state: state._has_total(player, 2)) - set_rule(world.get_location(("Tile 8"), player), lambda state: state._has_total(player, 3)) - set_rule(world.get_location(("Tile 9"), player), lambda state: state._has_total(player, 4)) - set_rule(world.get_location(("Tile 10"), player), lambda state: state._has_total(player, 5)) - set_rule(world.get_location(("Tile 11"), player), lambda state: state._has_total(player, 6)) - set_rule(world.get_location(("Tile 12"), player), lambda state: state._has_total(player, 7)) - set_rule(world.get_location(("Tile 13"), player), lambda state: state._has_total(player, 8)) - set_rule(world.get_location(("Tile 14"), player), lambda state: state._has_total(player, 9)) - set_rule(world.get_location(("Tile 15"), player), lambda state: state._has_total(player, 10)) - set_rule(world.get_location(("Tile 16"), player), lambda state: state._has_total(player, 11)) - set_rule(world.get_location(("Tile 17"), player), lambda state: state._has_total(player, 12)) - set_rule(world.get_location(("Tile 18"), player), lambda state: state._has_total(player, 13)) - set_rule(world.get_location(("Tile 19"), player), lambda state: state._has_total(player, 14)) - set_rule(world.get_location(("Tile 20"), player), lambda state: state._has_total(player, 15)) - set_rule(world.get_location(("Tile 21"), player), lambda state: state._has_total(player, 16)) - set_rule(world.get_location(("Tile 22"), player), lambda state: state._has_total(player, 17)) - set_rule(world.get_location(("Tile 23"), player), lambda state: state._has_total(player, 18)) - set_rule(world.get_location(("Tile 24"), player), lambda state: state._has_total(player, 19)) - set_rule(world.get_location(("Tile 25"), player), lambda state: state._has_total(player, 20)) + set_rule(world.get_location("Tile 6", player), lambda state: _has_total(state, player, 1)) + set_rule(world.get_location("Tile 7", player), lambda state: _has_total(state, player, 2)) + set_rule(world.get_location("Tile 8", player), lambda state: _has_total(state, player, 3)) + set_rule(world.get_location("Tile 9", player), lambda state: _has_total(state, player, 4)) + set_rule(world.get_location("Tile 10", player), lambda state: _has_total(state, player, 5)) + set_rule(world.get_location("Tile 11", player), lambda state: _has_total(state, player, 6)) + set_rule(world.get_location("Tile 12", player), lambda state: _has_total(state, player, 7)) + set_rule(world.get_location("Tile 13", player), lambda state: _has_total(state, player, 8)) + set_rule(world.get_location("Tile 14", player), lambda state: _has_total(state, player, 9)) + set_rule(world.get_location("Tile 15", player), lambda state: _has_total(state, player, 10)) + set_rule(world.get_location("Tile 16", player), lambda state: _has_total(state, player, 11)) + set_rule(world.get_location("Tile 17", player), lambda state: _has_total(state, player, 12)) + set_rule(world.get_location("Tile 18", player), lambda state: _has_total(state, player, 13)) + set_rule(world.get_location("Tile 19", player), lambda state: _has_total(state, player, 14)) + set_rule(world.get_location("Tile 20", player), lambda state: _has_total(state, player, 15)) + set_rule(world.get_location("Tile 21", player), lambda state: _has_total(state, player, 16)) + set_rule(world.get_location("Tile 22", player), lambda state: _has_total(state, player, 17)) + set_rule(world.get_location("Tile 23", player), lambda state: _has_total(state, player, 18)) + set_rule(world.get_location("Tile 24", player), lambda state: _has_total(state, player, 19)) + set_rule(world.get_location("Tile 25", player), lambda state: _has_total(state, player, 20)) # Sets rules on completion condition diff --git a/worlds/checksfinder/__init__.py b/worlds/checksfinder/__init__.py index 9ca16ca0d2..621e8f5c37 100644 --- a/worlds/checksfinder/__init__.py +++ b/worlds/checksfinder/__init__.py @@ -14,8 +14,8 @@ class ChecksFinderWeb(WebWorld): "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers " "single-player, multiworld, and related software.", "English", - "checksfinder_en.md", - "checksfinder/en", + "setup_en.md", + "setup/en", ["Mewlif"] )] @@ -45,7 +45,7 @@ class ChecksFinderWorld(World): 'race': self.multiworld.is_race, } - def generate_basic(self): + def create_items(self): # Generate item pool itempool = [] @@ -69,8 +69,8 @@ class ChecksFinderWorld(World): def create_regions(self): menu = Region("Menu", self.player, self.multiworld) board = Region("Board", self.player, self.multiworld) - board.locations = [ChecksFinderAdvancement(self.player, loc_name, loc_data.id, board) - for loc_name, loc_data in advancement_table.items() if loc_data.region == board.name] + board.locations += [ChecksFinderAdvancement(self.player, loc_name, loc_data.id, board) + for loc_name, loc_data in advancement_table.items() if loc_data.region == board.name] connection = Entrance(self.player, "New Board", menu) menu.exits.append(connection) diff --git a/worlds/checksfinder/docs/en_ChecksFinder.md b/worlds/checksfinder/docs/en_ChecksFinder.md index bd82660b09..96fb0529df 100644 --- a/worlds/checksfinder/docs/en_ChecksFinder.md +++ b/worlds/checksfinder/docs/en_ChecksFinder.md @@ -14,11 +14,18 @@ many checks as you have gained items, plus five to start with being available. ## When the player receives an item, what happens? When the player receives an item in ChecksFinder, it either can make the future boards they play be bigger in width or -height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being -bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a number +height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being +bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a +number next to an icon, the number is how many you have gotten and the icon represents which item it is. ## What is the victory condition? Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map -Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received. \ No newline at end of file +Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received. + +## Unique Local Commands + +The following command is only available when using the ChecksFinderClient to play with Archipelago. + +- `/resync` Manually trigger a resync. diff --git a/worlds/checksfinder/docs/checksfinder_en.md b/worlds/checksfinder/docs/setup_en.md similarity index 100% rename from worlds/checksfinder/docs/checksfinder_en.md rename to worlds/checksfinder/docs/setup_en.md diff --git a/worlds/dark_souls_3/Items.py b/worlds/dark_souls_3/Items.py index 754282e736..a13235b12a 100644 --- a/worlds/dark_souls_3/Items.py +++ b/worlds/dark_souls_3/Items.py @@ -1271,6 +1271,14 @@ _cut_content_items = [DS3ItemData(row[0], row[1], False, row[2]) for row in [ ("Dorris Swarm", 0x40393870, DS3ItemCategory.SKIP), ]] +item_descriptions = { + "Cinders": """ + All four Cinders of a Lord. + + Once you have these four, you can fight Soul of Cinder and win the game. + """, +} + _all_items = _vanilla_items + _dlc_items item_dictionary = {item_data.name: item_data for item_data in _all_items} diff --git a/worlds/dark_souls_3/Options.py b/worlds/dark_souls_3/Options.py index d613e47334..df0bb953b8 100644 --- a/worlds/dark_souls_3/Options.py +++ b/worlds/dark_souls_3/Options.py @@ -171,6 +171,16 @@ class MaxLevelsIn10WeaponPoolOption(Range): default = 10 +class EarlySmallLothricBanner(Choice): + """This option makes it so the user can choose to force the Small Lothric Banner into an early sphere in their world or + into an early sphere across all worlds.""" + display_name = "Early Small Lothric Banner" + option_off = 0 + option_early_global = 1 + option_early_local = 2 + default = option_off + + class LateBasinOfVowsOption(Toggle): """This option makes it so the Basin of Vows is still randomized, but guarantees you that you wont have to venture into Lothric Castle to find your Small Lothric Banner to get out of High Wall of Lothric. So you may find Basin of Vows early, @@ -215,6 +225,7 @@ dark_souls_options: typing.Dict[str, Option] = { "max_levels_in_5": MaxLevelsIn5WeaponPoolOption, "min_levels_in_10": MinLevelsIn10WeaponPoolOption, "max_levels_in_10": MaxLevelsIn10WeaponPoolOption, + "early_banner": EarlySmallLothricBanner, "late_basin_of_vows": LateBasinOfVowsOption, "late_dlc": LateDLCOption, "no_spell_requirements": NoSpellRequirementsOption, diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index b78ff0548a..7ee6c2a641 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -7,9 +7,9 @@ from Options import Toggle from worlds.AutoWorld import World, WebWorld from worlds.generic.Rules import set_rule, add_rule, add_item_rule -from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names +from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names, item_descriptions from .Locations import DarkSouls3Location, DS3LocationCategory, location_tables, location_dictionary -from .Options import RandomizeWeaponLevelOption, PoolTypeOption, dark_souls_options +from .Options import RandomizeWeaponLevelOption, PoolTypeOption, EarlySmallLothricBanner, dark_souls_options class DarkSouls3Web(WebWorld): @@ -52,6 +52,15 @@ class DarkSouls3World(World): 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" + } + } + item_descriptions = item_descriptions def __init__(self, multiworld: MultiWorld, player: int): @@ -77,6 +86,10 @@ class DarkSouls3World(World): self.enabled_location_categories.add(DS3LocationCategory.NPC) if self.multiworld.enable_key_locations[self.player] == Toggle.option_true: self.enabled_location_categories.add(DS3LocationCategory.KEY) + if self.multiworld.early_banner[self.player] == EarlySmallLothricBanner.option_early_global: + self.multiworld.early_items[self.player]['Small Lothric Banner'] = 1 + elif self.multiworld.early_banner[self.player] == EarlySmallLothricBanner.option_early_local: + self.multiworld.local_early_items[self.player]['Small Lothric Banner'] = 1 if self.multiworld.enable_boss_locations[self.player] == Toggle.option_true: self.enabled_location_categories.add(DS3LocationCategory.BOSS) if self.multiworld.enable_misc_locations[self.player] == Toggle.option_true: diff --git a/worlds/dark_souls_3/docs/setup_en.md b/worlds/dark_souls_3/docs/setup_en.md index d9dbb2e547..7a3ca4e9bd 100644 --- a/worlds/dark_souls_3/docs/setup_en.md +++ b/worlds/dark_souls_3/docs/setup_en.md @@ -21,7 +21,20 @@ This client has only been tested with the Official Steam version of the game at ## Downpatching Dark Souls III -Follow instructions from the [speedsouls wiki](https://wiki.speedsouls.com/darksouls3:Downpatching) to download version 1.15. Your download command, including the correct depot and manifest ids, will be "download_depot 374320 374321 4471176929659548333" +To downpatch DS3 for use with Archipelago, use the following instructions from the speedsouls wiki database. + +1. Launch Steam (in online mode). +2. Press the Windows Key + R. This will open the Run window. +3. Open the Steam console by typing the following string: steam://open/console , Steam should now open in Console Mode. +4. Insert the string of the depot you wish to download. For the AP supported v1.15, you will want to use: download_depot 374320 374321 4471176929659548333. +5. Steam will now download the depot. Note: There is no progress bar of the download in Steam, but it is still downloading in the background. +6. Turn off auto-updates in Steam by right-clicking Dark Souls III in your library > Properties > Updates > set "Automatic Updates" to "Only update this game when I launch it" (or change the value for AutoUpdateBehavior to 1 in "\Steam\steamapps\appmanifest_374320.acf"). +7. Back up your existing game folder in "\Steam\steamapps\common\DARK SOULS III". +8. Return back to Steam console. Once the download is complete, it should say so along with the temporary local directory in which the depot has been stored. This is usually something like "\Steam\steamapps\content\app_XXXXXX\depot_XXXXXX". Back up this game folder as well. +9. Delete your existing game folder in "\Steam\steamapps\common\DARK SOULS III", then replace it with your game folder in "\Steam\steamapps\content\app_XXXXXX\depot_XXXXXX". +10. Back up and delete your save file "DS30000.sl2" in AppData. AppData is hidden by default. To locate it, press Windows Key + R, type %appdata% and hit enter or: open File Explorer > View > Hidden Items and follow "C:\Users\your username\AppData\Roaming\DarkSoulsIII\numbers". +11. If you did all these steps correctly, you should be able to confirm your game version in the upper left corner after launching Dark Souls III. + ## Installing the Archipelago mod diff --git a/worlds/dkc3/docs/setup_en.md b/worlds/dkc3/docs/setup_en.md index bb10756300..9c4197286e 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 diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 61f4cd30fa..e7008f7b12 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -1,15 +1,18 @@ 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): game: str = "DLCQuest" + coins: int = 0 + coin_suffix: str = "" offset = 120_000 @@ -93,38 +96,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 08d37e7812..a9fdd00a20 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 a1674a4d5a..769acbec15 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, NamedRange, PerGameCommonOptions class DoubleJumpGlitch(Choice): @@ -49,7 +33,7 @@ class CoinSanity(Choice): default = 0 -class CoinSanityRange(SpecialRange): +class CoinSanityRange(NamedRange): """This is the amount of coins in a coin bundle You need to collect that number of coins to get a location check, and when receiving coin items, you will get bundles of this size It is highly recommended to not set this value below 10, as it generates a very large number of boring locations and items. @@ -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 8135a1c362..6dad9fc10c 100644 --- a/worlds/dlcquest/Regions.py +++ b/worlds/dlcquest/Regions.py @@ -1,325 +1,190 @@ 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" +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(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)) + event = create_event(player, number_coin) + event.coins = coin + event.coin_suffix = suffix + location.place_locked_item(event) -def add_coin_dlcquest(region: Region, Coin: int, player: int): - number_coin = f"{Coin} coins" - location_coin = f"{region.name} coins" - location = DLCQuestLocation(player, location_coin, None, region) - region.locations.append(location) - location.place_locked_item(create_event(player, number_coin)) +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(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) +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 - if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[ - Options.Campaign] == Options.Campaign.option_both: + 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) - 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) + create_victory_event(region_final_boss_room, "Winning Basic", "Victory Basic", player) - 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) + connect_entrances_basic(player, world) - 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) +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 - 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) + 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) - 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) + create_victory_event(region_final_boss, "Winning Freemium", "Victory Freemium", player) - 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) + connect_entrances_lfod(multiworld, player) - 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) +def conditional_location(condition: bool, location: str) -> List[str]: + return conditional_locations(condition, [location]) - 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) +def conditional_locations(condition: bool, locations: List[str]) -> List[str]: + return locations if condition else [] - 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) +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) - 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)) +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) - world.get_entrance("Moving", player).connect(world.get_region("Movement Pack", player)) - world.get_entrance("Tree", player).connect(world.get_region("Behind Tree", player)) +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 - 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)) +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)) - 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)) +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)) - 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)) +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)) - 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)) +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") - 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)) +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") - 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) +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 - 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)) + 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 d2495121f4..5792d9c3ab 100644 --- a/worlds/dlcquest/Rules.py +++ b/worlds/dlcquest/Rules.py @@ -1,56 +1,40 @@ 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): +def create_event(player, event: str) -> DLCQuestItem: return DLCQuestItem(event, ItemClassification.progression, None, player) +def has_enough_coin(player: int, coin: int): + return lambda state: state.prog_items[player][" coins"] >= coin + + +def has_enough_coin_freemium(player: int, coin: int): + return lambda state: state.prog_items[player][" coins freemium"] >= coin + + def set_rules(world, player, World_Options: Options.DLCQuestOptions): - def has_enough_coin(player: int, coin: int): - def has_coin(state, player: int, coins: int): - coin_possessed = 0 - for i in [4, 7, 9, 10, 46, 50, 60, 76, 89, 100, 171, 203]: - name_coin = f"{i} coins" - if state.has(name_coin, player): - coin_possessed += i - - return coin_possessed >= coins - - return lambda state: has_coin(state, player, coin) - - def has_enough_coin_freemium(player: int, coin: int): - def has_coin(state, player: int, coins: int): - coin_possessed = 0 - for i in [20, 50, 90, 95, 130, 150, 154, 200]: - name_coin = f"{i} coins freemium" - if state.has(name_coin, player): - coin_possessed += i - - return coin_possessed >= coins - - return lambda state: has_coin(state, player, coin) - - set_basic_rules(World_Options, has_enough_coin, player, world) - set_lfod_rules(World_Options, has_enough_coin_freemium, player, world) + set_basic_rules(World_Options, player, world) + set_lfod_rules(World_Options, player, world) set_completion_condition(World_Options, player, world) -def set_basic_rules(World_Options, has_enough_coin, player, world): - if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die: +def set_basic_rules(World_Options, player, world): + 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) set_basic_shuffled_items_rules(World_Options, player, world) set_double_jump_glitchless_rules(World_Options, player, world) set_easy_double_jump_glitch_rules(World_Options, player, world) - self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, player, world) - set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player, world) + self_basic_coinsanity_funded_purchase_rules(World_Options, player, world) + set_basic_self_funded_purchase_rules(World_Options, player, world) self_basic_win_condition(World_Options, player, world) @@ -66,12 +50,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 +71,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 +92,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 +107,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)) @@ -131,71 +115,71 @@ def set_easy_double_jump_glitch_rules(World_Options, player, world): lambda state: state.has("Double Jump Pack", player)) -def self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, player, world): - if World_Options[Options.CoinSanity] != Options.CoinSanity.option_coin: +def self_basic_coinsanity_funded_purchase_rules(World_Options, player, world): + 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: +def set_basic_self_funded_purchase_rules(World_Options, player, world): + if World_Options.coinsanity != Options.CoinSanity.option_none: return set_rule(world.get_location("Movement Pack", player), has_enough_coin(player, 4)) @@ -232,23 +216,23 @@ 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: +def set_lfod_rules(World_Options, player, world): + if World_Options.campaign == Options.Campaign.option_basic: return set_lfod_entrance_rules(player, world) set_boss_door_requirements_rules(player, world) set_lfod_self_obtained_items_rules(World_Options, player, world) set_lfod_shuffled_items_rules(World_Options, player, world) - self_lfod_coinsanity_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world) + self_lfod_coinsanity_funded_purchase_rules(World_Options, player, world) set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world) @@ -297,7 +281,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 +293,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)) @@ -327,80 +311,80 @@ def set_lfod_shuffled_items_rules(World_Options, player, world): lambda state: state.can_reach("Cut Content", 'region', player)) -def self_lfod_coinsanity_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world): - if World_Options[Options.CoinSanity] != Options.CoinSanity.option_coin: +def self_lfod_coinsanity_funded_purchase_rules(World_Options, player, world): + 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 +426,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 9569d0efcc..c22b7cd984 100644 --- a/worlds/dlcquest/__init__.py +++ b/worlds/dlcquest/__init__.py @@ -1,12 +1,13 @@ -from typing import Dict, Any, Iterable, Optional, 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 typing import Union + +from BaseClasses import Tutorial, CollectionState +from worlds.AutoWorld import WebWorld, World from . import Options +from .Items import DLCQuestItem, ItemData, create_items, item_table, items_by_group, Group +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) @@ -61,31 +60,51 @@ class DLCqworld(World): created_items = create_items(self, self.options, locations_count + len(items_to_exclude), self.multiworld.random) self.multiworld.itempool += created_items - self.multiworld.early_items[self.player]["Movement Pack"] = 1 + + if self.options.campaign == Options.Campaign.option_basic or self.options.campaign == Options.Campaign.option_both: + self.multiworld.early_items[self.player]["Movement Pack"] = 1 for item in items_to_exclude: if item in self.multiworld.itempool: 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")) - def create_item(self, item: Union[str, ItemData]) -> DLCQuestItem: if isinstance(item, str): item = item_table[item] return DLCQuestItem(item.name, item.classification, item.code, self.player) + def get_filler_item_name(self) -> str: + trap = self.multiworld.random.choice(items_by_group[Group.Trap]) + return trap.name + 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 + + def collect(self, state: CollectionState, item: DLCQuestItem) -> bool: + change = super().collect(state, item) + if change: + suffix = item.coin_suffix + if suffix: + state.prog_items[self.player][suffix] += item.coins + return change + + def remove(self, state: CollectionState, item: DLCQuestItem) -> bool: + change = super().remove(state, item) + if change: + suffix = item.coin_suffix + if suffix: + state.prog_items[self.player][suffix] -= item.coins + return change diff --git a/worlds/dlcquest/test/TestItemShuffle.py b/worlds/dlcquest/test/TestItemShuffle.py new file mode 100644 index 0000000000..bfe999246a --- /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 0000000000..3e9acac7e7 --- /dev/null +++ b/worlds/dlcquest/test/TestOptionsLong.py @@ -0,0 +1,87 @@ +from typing import Dict + +from BaseClasses import MultiWorld +from Options import NamedRange +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, NamedRange): + 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 0000000000..e998bd8a5e --- /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 0000000000..e69de29bb2 diff --git a/worlds/dlcquest/test/checks/world_checks.py b/worlds/dlcquest/test/checks/world_checks.py new file mode 100644 index 0000000000..a97093d620 --- /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 0000000000..4a4b46e906 --- /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/Items.py b/worlds/doom_1993/Items.py index fe5576c4df..3c5124d4d5 100644 --- a/worlds/doom_1993/Items.py +++ b/worlds/doom_1993/Items.py @@ -1165,6 +1165,7 @@ item_table: Dict[int, ItemDict] = { item_name_groups: Dict[str, Set[str]] = { 'Ammos': {'Box of bullets', 'Box of rockets', 'Box of shotgun shells', 'Energy cell pack', }, + 'Computer area maps': {'Against Thee Wickedly (E4M6) - Computer area map', 'And Hell Followed (E4M7) - Computer area map', 'Central Processing (E1M6) - Computer area map', 'Command Center (E2M5) - Computer area map', 'Command Control (E1M4) - Computer area map', 'Computer Station (E1M7) - Computer area map', 'Containment Area (E2M2) - Computer area map', 'Deimos Anomaly (E2M1) - Computer area map', 'Deimos Lab (E2M4) - Computer area map', 'Dis (E3M8) - Computer area map', 'Fear (E4M9) - Computer area map', 'Fortress of Mystery (E2M9) - Computer area map', 'Halls of the Damned (E2M6) - Computer area map', 'Hangar (E1M1) - Computer area map', 'Hell Beneath (E4M1) - Computer area map', 'Hell Keep (E3M1) - Computer area map', 'House of Pain (E3M4) - Computer area map', 'Limbo (E3M7) - Computer area map', 'Military Base (E1M9) - Computer area map', 'Mt. Erebus (E3M6) - Computer area map', 'Nuclear Plant (E1M2) - Computer area map', 'Pandemonium (E3M3) - Computer area map', 'Perfect Hatred (E4M2) - Computer area map', 'Phobos Anomaly (E1M8) - Computer area map', 'Phobos Lab (E1M5) - Computer area map', 'Refinery (E2M3) - Computer area map', 'Sever the Wicked (E4M3) - Computer area map', 'Slough of Despair (E3M2) - Computer area map', 'Spawning Vats (E2M7) - Computer area map', 'They Will Repent (E4M5) - Computer area map', 'Tower of Babel (E2M8) - Computer area map', 'Toxin Refinery (E1M3) - Computer area map', 'Unholy Cathedral (E3M5) - Computer area map', 'Unruly Evil (E4M4) - Computer area map', 'Unto the Cruel (E4M8) - Computer area map', 'Warrens (E3M9) - Computer area map', }, 'Keys': {'Against Thee Wickedly (E4M6) - Blue skull key', 'Against Thee Wickedly (E4M6) - Red skull key', 'Against Thee Wickedly (E4M6) - Yellow skull key', 'And Hell Followed (E4M7) - Blue skull key', 'And Hell Followed (E4M7) - Red skull key', 'And Hell Followed (E4M7) - Yellow skull key', 'Central Processing (E1M6) - Blue keycard', 'Central Processing (E1M6) - Red keycard', 'Central Processing (E1M6) - Yellow keycard', 'Command Control (E1M4) - Blue keycard', 'Command Control (E1M4) - Yellow keycard', 'Computer Station (E1M7) - Blue keycard', 'Computer Station (E1M7) - Red keycard', 'Computer Station (E1M7) - Yellow keycard', 'Containment Area (E2M2) - Blue keycard', 'Containment Area (E2M2) - Red keycard', 'Containment Area (E2M2) - Yellow keycard', 'Deimos Anomaly (E2M1) - Blue keycard', 'Deimos Anomaly (E2M1) - Red keycard', 'Deimos Lab (E2M4) - Blue keycard', 'Deimos Lab (E2M4) - Yellow keycard', 'Fear (E4M9) - Yellow skull key', 'Fortress of Mystery (E2M9) - Blue skull key', 'Fortress of Mystery (E2M9) - Red skull key', 'Fortress of Mystery (E2M9) - Yellow skull key', 'Halls of the Damned (E2M6) - Blue skull key', 'Halls of the Damned (E2M6) - Red skull key', 'Halls of the Damned (E2M6) - Yellow skull key', 'Hell Beneath (E4M1) - Blue skull key', 'Hell Beneath (E4M1) - Red skull key', 'House of Pain (E3M4) - Blue skull key', 'House of Pain (E3M4) - Red skull key', 'House of Pain (E3M4) - Yellow skull key', 'Limbo (E3M7) - Blue skull key', 'Limbo (E3M7) - Red skull key', 'Limbo (E3M7) - Yellow skull key', 'Military Base (E1M9) - Blue keycard', 'Military Base (E1M9) - Red keycard', 'Military Base (E1M9) - Yellow keycard', 'Mt. Erebus (E3M6) - Blue skull key', 'Nuclear Plant (E1M2) - Red keycard', 'Pandemonium (E3M3) - Blue skull key', 'Perfect Hatred (E4M2) - Blue skull key', 'Perfect Hatred (E4M2) - Yellow skull key', 'Phobos Lab (E1M5) - Blue keycard', 'Phobos Lab (E1M5) - Yellow keycard', 'Refinery (E2M3) - Blue keycard', 'Sever the Wicked (E4M3) - Blue skull key', 'Sever the Wicked (E4M3) - Red skull key', 'Slough of Despair (E3M2) - Blue skull key', 'Spawning Vats (E2M7) - Blue keycard', 'Spawning Vats (E2M7) - Red keycard', 'Spawning Vats (E2M7) - Yellow keycard', 'They Will Repent (E4M5) - Blue skull key', 'They Will Repent (E4M5) - Red skull key', 'They Will Repent (E4M5) - Yellow skull key', 'Toxin Refinery (E1M3) - Blue keycard', 'Toxin Refinery (E1M3) - Yellow keycard', 'Unholy Cathedral (E3M5) - Blue skull key', 'Unholy Cathedral (E3M5) - Yellow skull key', 'Unruly Evil (E4M4) - Red skull key', 'Unto the Cruel (E4M8) - Red skull key', 'Unto the Cruel (E4M8) - Yellow skull key', 'Warrens (E3M9) - Blue skull key', 'Warrens (E3M9) - Red skull key', }, 'Levels': {'Against Thee Wickedly (E4M6)', 'And Hell Followed (E4M7)', 'Central Processing (E1M6)', 'Command Center (E2M5)', 'Command Control (E1M4)', 'Computer Station (E1M7)', 'Containment Area (E2M2)', 'Deimos Anomaly (E2M1)', 'Deimos Lab (E2M4)', 'Dis (E3M8)', 'Fear (E4M9)', 'Fortress of Mystery (E2M9)', 'Halls of the Damned (E2M6)', 'Hangar (E1M1)', 'Hell Beneath (E4M1)', 'Hell Keep (E3M1)', 'House of Pain (E3M4)', 'Limbo (E3M7)', 'Military Base (E1M9)', 'Mt. Erebus (E3M6)', 'Nuclear Plant (E1M2)', 'Pandemonium (E3M3)', 'Perfect Hatred (E4M2)', 'Phobos Anomaly (E1M8)', 'Phobos Lab (E1M5)', 'Refinery (E2M3)', 'Sever the Wicked (E4M3)', 'Slough of Despair (E3M2)', 'Spawning Vats (E2M7)', 'They Will Repent (E4M5)', 'Tower of Babel (E2M8)', 'Toxin Refinery (E1M3)', 'Unholy Cathedral (E3M5)', 'Unruly Evil (E4M4)', 'Unto the Cruel (E4M8)', 'Warrens (E3M9)', }, 'Powerups': {'Armor', 'Berserk', 'Invulnerability', 'Mega Armor', 'Partial invisibility', 'Supercharge', }, diff --git a/worlds/doom_1993/Locations.py b/worlds/doom_1993/Locations.py index 778efb4661..2cbb9b9d15 100644 --- a/worlds/doom_1993/Locations.py +++ b/worlds/doom_1993/Locations.py @@ -1968,7 +1968,7 @@ location_table: Dict[int, LocationDict] = { 'map': 2, 'index': -1, 'doom_type': -1, - 'region': "Containment Area (E2M2) Red"}, + 'region': "Containment Area (E2M2) Red Exit"}, 351326: {'name': 'Deimos Anomaly (E2M1) - Exit', 'episode': 2, 'map': 1, diff --git a/worlds/doom_1993/Options.py b/worlds/doom_1993/Options.py index 72bb7c3aea..59f7bcef49 100644 --- a/worlds/doom_1993/Options.py +++ b/worlds/doom_1993/Options.py @@ -1,6 +1,18 @@ import typing -from Options import AssembleOptions, Choice, Toggle, DeathLink, DefaultOnToggle +from Options import AssembleOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool + + +class Goal(Choice): + """ + Choose the main goal. + complete_all_levels: All levels of the selected episodes + complete_boss_levels: Boss levels (E#M8) of selected episodes + """ + display_name = "Goal" + option_complete_all_levels = 0 + option_complete_boss_levels = 1 + default = 0 class Difficulty(Choice): @@ -27,11 +39,13 @@ class RandomMonsters(Choice): vanilla: No randomization shuffle: Monsters are shuffled within the level random_balanced: Monsters are completely randomized, but balanced based on existing ratio in the level. (Small monsters vs medium vs big) + random_chaotic: Monsters are completely randomized, but balanced based on existing ratio in the entire game. """ display_name = "Random Monsters" option_vanilla = 0 option_shuffle = 1 option_random_balanced = 2 + option_random_chaotic = 3 default = 1 @@ -49,6 +63,34 @@ class RandomPickups(Choice): default = 1 +class RandomMusic(Choice): + """ + Level musics will be randomized. + vanilla: No randomization + shuffle_selected: Selected episodes' levels will be shuffled + shuffle_game: All the music will be shuffled + """ + display_name = "Random Music" + option_vanilla = 0 + option_shuffle_selected = 1 + option_shuffle_game = 2 + default = 0 + + +class FlipLevels(Choice): + """ + Flip levels on one axis. + vanilla: No flipping + flipped: All levels are flipped + randomly_flipped: Random levels are flipped + """ + display_name = "Flip Levels" + option_vanilla = 0 + option_flipped = 1 + option_randomly_flipped = 2 + default = 0 + + class AllowDeathLogic(Toggle): """Some locations require a timed puzzle that can only be tried once. After which, if the player failed to get it, the location cannot be checked anymore. @@ -56,12 +98,24 @@ class AllowDeathLogic(Toggle): Get killed in the current map. The map will reset, you can now attempt the puzzle again.""" display_name = "Allow Death Logic" + +class Pro(Toggle): + """Include difficult tricks into rules. Mostly employed by speed runners. + i.e.: Leaps across to a locked area, trigger a switch behind a window at the right angle, etc.""" + display_name = "Pro Doom" + class StartWithComputerAreaMaps(Toggle): """Give the player all Computer Area Map items from the start.""" display_name = "Start With Computer Area Maps" +class ResetLevelOnDeath(DefaultOnToggle): + """When dying, levels are reset and monsters respawned. But inventory and checks are kept. + Turning this setting off is considered easy mode. Good for new players that don't know the levels well.""" + display_name="Reset Level on Death" + + class Episode1(DefaultOnToggle): """Knee-Deep in the Dead. If none of the episodes are chosen, Episode 1 will be chosen by default.""" @@ -87,12 +141,18 @@ class Episode4(Toggle): options: typing.Dict[str, AssembleOptions] = { + "start_inventory_from_pool": StartInventoryPool, + "goal": Goal, "difficulty": Difficulty, "random_monsters": RandomMonsters, "random_pickups": RandomPickups, + "random_music": RandomMusic, + "flip_levels": FlipLevels, "allow_death_logic": AllowDeathLogic, + "pro": Pro, "start_with_computer_area_maps": StartWithComputerAreaMaps, "death_link": DeathLink, + "reset_level_on_death": ResetLevelOnDeath, "episode1": Episode1, "episode2": Episode2, "episode3": Episode3, diff --git a/worlds/doom_1993/Regions.py b/worlds/doom_1993/Regions.py index 602c29f5bd..f013bdceaf 100644 --- a/worlds/doom_1993/Regions.py +++ b/worlds/doom_1993/Regions.py @@ -3,11 +3,15 @@ from typing import List from BaseClasses import TypedDict -class RegionDict(TypedDict, total=False): +class ConnectionDict(TypedDict, total=False): + target: str + pro: bool + +class RegionDict(TypedDict, total=False): name: str connects_to_hub: bool episode: int - connections: List[str] + connections: List[ConnectionDict] regions:List[RegionDict] = [ @@ -21,121 +25,131 @@ regions:List[RegionDict] = [ {"name":"Nuclear Plant (E1M2) Main", "connects_to_hub":True, "episode":1, - "connections":["Nuclear Plant (E1M2) Red"]}, + "connections":[{"target":"Nuclear Plant (E1M2) Red","pro":False}]}, {"name":"Nuclear Plant (E1M2) Red", "connects_to_hub":False, "episode":1, - "connections":["Nuclear Plant (E1M2) Main"]}, + "connections":[{"target":"Nuclear Plant (E1M2) Main","pro":False}]}, # Toxin Refinery (E1M3) {"name":"Toxin Refinery (E1M3) Main", "connects_to_hub":True, "episode":1, - "connections":["Toxin Refinery (E1M3) Blue"]}, + "connections":[{"target":"Toxin Refinery (E1M3) Blue","pro":False}]}, {"name":"Toxin Refinery (E1M3) Blue", "connects_to_hub":False, "episode":1, "connections":[ - "Toxin Refinery (E1M3) Yellow", - "Toxin Refinery (E1M3) Main"]}, + {"target":"Toxin Refinery (E1M3) Yellow","pro":False}, + {"target":"Toxin Refinery (E1M3) Main","pro":False}]}, {"name":"Toxin Refinery (E1M3) Yellow", "connects_to_hub":False, "episode":1, - "connections":["Toxin Refinery (E1M3) Blue"]}, + "connections":[{"target":"Toxin Refinery (E1M3) Blue","pro":False}]}, # Command Control (E1M4) {"name":"Command Control (E1M4) Main", "connects_to_hub":True, "episode":1, "connections":[ - "Command Control (E1M4) Blue", - "Command Control (E1M4) Yellow"]}, + {"target":"Command Control (E1M4) Blue","pro":False}, + {"target":"Command Control (E1M4) Yellow","pro":False}, + {"target":"Command Control (E1M4) Ledge","pro":True}]}, {"name":"Command Control (E1M4) Blue", "connects_to_hub":False, "episode":1, - "connections":["Command Control (E1M4) Main"]}, + "connections":[ + {"target":"Command Control (E1M4) Ledge","pro":False}, + {"target":"Command Control (E1M4) Main","pro":False}]}, {"name":"Command Control (E1M4) Yellow", "connects_to_hub":False, "episode":1, - "connections":["Command Control (E1M4) Main"]}, + "connections":[{"target":"Command Control (E1M4) Main","pro":False}]}, + {"name":"Command Control (E1M4) Ledge", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"Command Control (E1M4) Main","pro":False}, + {"target":"Command Control (E1M4) Blue","pro":False}, + {"target":"Command Control (E1M4) Yellow","pro":False}]}, # Phobos Lab (E1M5) {"name":"Phobos Lab (E1M5) Main", "connects_to_hub":True, "episode":1, - "connections":["Phobos Lab (E1M5) Yellow"]}, + "connections":[{"target":"Phobos Lab (E1M5) Yellow","pro":False}]}, {"name":"Phobos Lab (E1M5) Yellow", "connects_to_hub":False, "episode":1, "connections":[ - "Phobos Lab (E1M5) Main", - "Phobos Lab (E1M5) Blue", - "Phobos Lab (E1M5) Green"]}, + {"target":"Phobos Lab (E1M5) Main","pro":False}, + {"target":"Phobos Lab (E1M5) Blue","pro":False}, + {"target":"Phobos Lab (E1M5) Green","pro":False}]}, {"name":"Phobos Lab (E1M5) Blue", "connects_to_hub":False, "episode":1, "connections":[ - "Phobos Lab (E1M5) Green", - "Phobos Lab (E1M5) Yellow"]}, + {"target":"Phobos Lab (E1M5) Green","pro":False}, + {"target":"Phobos Lab (E1M5) Yellow","pro":False}]}, {"name":"Phobos Lab (E1M5) Green", "connects_to_hub":False, "episode":1, "connections":[ - "Phobos Lab (E1M5) Main", - "Phobos Lab (E1M5) Blue"]}, + {"target":"Phobos Lab (E1M5) Main","pro":False}, + {"target":"Phobos Lab (E1M5) Blue","pro":False}]}, # Central Processing (E1M6) {"name":"Central Processing (E1M6) Main", "connects_to_hub":True, "episode":1, "connections":[ - "Central Processing (E1M6) Yellow", - "Central Processing (E1M6) Red", - "Central Processing (E1M6) Blue", - "Central Processing (E1M6) Nukage"]}, + {"target":"Central Processing (E1M6) Yellow","pro":False}, + {"target":"Central Processing (E1M6) Red","pro":False}, + {"target":"Central Processing (E1M6) Blue","pro":False}, + {"target":"Central Processing (E1M6) Nukage","pro":False}]}, {"name":"Central Processing (E1M6) Red", "connects_to_hub":False, "episode":1, - "connections":["Central Processing (E1M6) Main"]}, + "connections":[{"target":"Central Processing (E1M6) Main","pro":False}]}, {"name":"Central Processing (E1M6) Blue", "connects_to_hub":False, "episode":1, - "connections":["Central Processing (E1M6) Main"]}, + "connections":[{"target":"Central Processing (E1M6) Main","pro":False}]}, {"name":"Central Processing (E1M6) Yellow", "connects_to_hub":False, "episode":1, - "connections":["Central Processing (E1M6) Main"]}, + "connections":[{"target":"Central Processing (E1M6) Main","pro":False}]}, {"name":"Central Processing (E1M6) Nukage", "connects_to_hub":False, "episode":1, - "connections":["Central Processing (E1M6) Yellow"]}, + "connections":[{"target":"Central Processing (E1M6) Yellow","pro":False}]}, # Computer Station (E1M7) {"name":"Computer Station (E1M7) Main", "connects_to_hub":True, "episode":1, "connections":[ - "Computer Station (E1M7) Red", - "Computer Station (E1M7) Yellow"]}, + {"target":"Computer Station (E1M7) Red","pro":False}, + {"target":"Computer Station (E1M7) Yellow","pro":False}]}, {"name":"Computer Station (E1M7) Blue", "connects_to_hub":False, "episode":1, - "connections":["Computer Station (E1M7) Yellow"]}, + "connections":[{"target":"Computer Station (E1M7) Yellow","pro":False}]}, {"name":"Computer Station (E1M7) Red", "connects_to_hub":False, "episode":1, - "connections":["Computer Station (E1M7) Main"]}, + "connections":[{"target":"Computer Station (E1M7) Main","pro":False}]}, {"name":"Computer Station (E1M7) Yellow", "connects_to_hub":False, "episode":1, "connections":[ - "Computer Station (E1M7) Blue", - "Computer Station (E1M7) Courtyard", - "Computer Station (E1M7) Main"]}, + {"target":"Computer Station (E1M7) Blue","pro":False}, + {"target":"Computer Station (E1M7) Courtyard","pro":False}, + {"target":"Computer Station (E1M7) Main","pro":False}]}, {"name":"Computer Station (E1M7) Courtyard", "connects_to_hub":False, "episode":1, - "connections":["Computer Station (E1M7) Yellow"]}, + "connections":[{"target":"Computer Station (E1M7) Yellow","pro":False}]}, # Phobos Anomaly (E1M8) {"name":"Phobos Anomaly (E1M8) Main", @@ -145,91 +159,98 @@ regions:List[RegionDict] = [ {"name":"Phobos Anomaly (E1M8) Start", "connects_to_hub":True, "episode":1, - "connections":["Phobos Anomaly (E1M8) Main"]}, + "connections":[{"target":"Phobos Anomaly (E1M8) Main","pro":False}]}, # Military Base (E1M9) {"name":"Military Base (E1M9) Main", "connects_to_hub":True, "episode":1, "connections":[ - "Military Base (E1M9) Blue", - "Military Base (E1M9) Yellow", - "Military Base (E1M9) Red"]}, + {"target":"Military Base (E1M9) Blue","pro":False}, + {"target":"Military Base (E1M9) Yellow","pro":False}, + {"target":"Military Base (E1M9) Red","pro":False}]}, {"name":"Military Base (E1M9) Blue", "connects_to_hub":False, "episode":1, - "connections":["Military Base (E1M9) Main"]}, + "connections":[{"target":"Military Base (E1M9) Main","pro":False}]}, {"name":"Military Base (E1M9) Red", "connects_to_hub":False, "episode":1, - "connections":["Military Base (E1M9) Main"]}, + "connections":[{"target":"Military Base (E1M9) Main","pro":False}]}, {"name":"Military Base (E1M9) Yellow", "connects_to_hub":False, "episode":1, - "connections":["Military Base (E1M9) Main"]}, + "connections":[{"target":"Military Base (E1M9) Main","pro":False}]}, # Deimos Anomaly (E2M1) {"name":"Deimos Anomaly (E2M1) Main", "connects_to_hub":True, "episode":2, "connections":[ - "Deimos Anomaly (E2M1) Red", - "Deimos Anomaly (E2M1) Blue"]}, + {"target":"Deimos Anomaly (E2M1) Red","pro":False}, + {"target":"Deimos Anomaly (E2M1) Blue","pro":False}]}, {"name":"Deimos Anomaly (E2M1) Blue", "connects_to_hub":False, "episode":2, - "connections":["Deimos Anomaly (E2M1) Main"]}, + "connections":[{"target":"Deimos Anomaly (E2M1) Main","pro":False}]}, {"name":"Deimos Anomaly (E2M1) Red", "connects_to_hub":False, "episode":2, - "connections":["Deimos Anomaly (E2M1) Main"]}, + "connections":[{"target":"Deimos Anomaly (E2M1) Main","pro":False}]}, # Containment Area (E2M2) {"name":"Containment Area (E2M2) Main", "connects_to_hub":True, "episode":2, "connections":[ - "Containment Area (E2M2) Yellow", - "Containment Area (E2M2) Blue", - "Containment Area (E2M2) Red"]}, + {"target":"Containment Area (E2M2) Yellow","pro":False}, + {"target":"Containment Area (E2M2) Blue","pro":False}, + {"target":"Containment Area (E2M2) Red","pro":False}, + {"target":"Containment Area (E2M2) Red Exit","pro":True}]}, {"name":"Containment Area (E2M2) Blue", "connects_to_hub":False, "episode":2, - "connections":["Containment Area (E2M2) Main"]}, + "connections":[{"target":"Containment Area (E2M2) Main","pro":False}]}, {"name":"Containment Area (E2M2) Red", "connects_to_hub":False, "episode":2, - "connections":["Containment Area (E2M2) Main"]}, + "connections":[ + {"target":"Containment Area (E2M2) Main","pro":False}, + {"target":"Containment Area (E2M2) Red Exit","pro":False}]}, {"name":"Containment Area (E2M2) Yellow", "connects_to_hub":False, "episode":2, - "connections":["Containment Area (E2M2) Main"]}, + "connections":[{"target":"Containment Area (E2M2) Main","pro":False}]}, + {"name":"Containment Area (E2M2) Red Exit", + "connects_to_hub":False, + "episode":2, + "connections":[]}, # Refinery (E2M3) {"name":"Refinery (E2M3) Main", "connects_to_hub":True, "episode":2, - "connections":["Refinery (E2M3) Blue"]}, + "connections":[{"target":"Refinery (E2M3) Blue","pro":False}]}, {"name":"Refinery (E2M3) Blue", "connects_to_hub":False, "episode":2, - "connections":["Refinery (E2M3) Main"]}, + "connections":[{"target":"Refinery (E2M3) Main","pro":False}]}, # Deimos Lab (E2M4) {"name":"Deimos Lab (E2M4) Main", "connects_to_hub":True, "episode":2, - "connections":["Deimos Lab (E2M4) Blue"]}, + "connections":[{"target":"Deimos Lab (E2M4) Blue","pro":False}]}, {"name":"Deimos Lab (E2M4) Blue", "connects_to_hub":False, "episode":2, "connections":[ - "Deimos Lab (E2M4) Main", - "Deimos Lab (E2M4) Yellow"]}, + {"target":"Deimos Lab (E2M4) Main","pro":False}, + {"target":"Deimos Lab (E2M4) Yellow","pro":False}]}, {"name":"Deimos Lab (E2M4) Yellow", "connects_to_hub":False, "episode":2, - "connections":["Deimos Lab (E2M4) Blue"]}, + "connections":[{"target":"Deimos Lab (E2M4) Blue","pro":False}]}, # Command Center (E2M5) {"name":"Command Center (E2M5) Main", @@ -242,47 +263,54 @@ regions:List[RegionDict] = [ "connects_to_hub":True, "episode":2, "connections":[ - "Halls of the Damned (E2M6) Blue Yellow Red", - "Halls of the Damned (E2M6) Yellow", - "Halls of the Damned (E2M6) One way Yellow"]}, + {"target":"Halls of the Damned (E2M6) Blue Yellow Red","pro":False}, + {"target":"Halls of the Damned (E2M6) Yellow","pro":False}, + {"target":"Halls of the Damned (E2M6) One way Yellow","pro":False}]}, {"name":"Halls of the Damned (E2M6) Yellow", "connects_to_hub":False, "episode":2, - "connections":["Halls of the Damned (E2M6) Main"]}, + "connections":[{"target":"Halls of the Damned (E2M6) Main","pro":False}]}, {"name":"Halls of the Damned (E2M6) Blue Yellow Red", "connects_to_hub":False, "episode":2, - "connections":["Halls of the Damned (E2M6) Main"]}, + "connections":[{"target":"Halls of the Damned (E2M6) Main","pro":False}]}, {"name":"Halls of the Damned (E2M6) One way Yellow", "connects_to_hub":False, "episode":2, - "connections":["Halls of the Damned (E2M6) Main"]}, + "connections":[{"target":"Halls of the Damned (E2M6) Main","pro":False}]}, # Spawning Vats (E2M7) {"name":"Spawning Vats (E2M7) Main", "connects_to_hub":True, "episode":2, "connections":[ - "Spawning Vats (E2M7) Blue", - "Spawning Vats (E2M7) Entrance Secret", - "Spawning Vats (E2M7) Red", - "Spawning Vats (E2M7) Yellow"]}, + {"target":"Spawning Vats (E2M7) Blue","pro":False}, + {"target":"Spawning Vats (E2M7) Entrance Secret","pro":False}, + {"target":"Spawning Vats (E2M7) Red","pro":False}, + {"target":"Spawning Vats (E2M7) Yellow","pro":False}, + {"target":"Spawning Vats (E2M7) Red Exit","pro":True}]}, {"name":"Spawning Vats (E2M7) Blue", "connects_to_hub":False, "episode":2, - "connections":["Spawning Vats (E2M7) Main"]}, + "connections":[{"target":"Spawning Vats (E2M7) Main","pro":False}]}, {"name":"Spawning Vats (E2M7) Yellow", "connects_to_hub":False, "episode":2, - "connections":["Spawning Vats (E2M7) Main"]}, + "connections":[{"target":"Spawning Vats (E2M7) Main","pro":False}]}, {"name":"Spawning Vats (E2M7) Red", "connects_to_hub":False, "episode":2, - "connections":["Spawning Vats (E2M7) Main"]}, + "connections":[ + {"target":"Spawning Vats (E2M7) Main","pro":False}, + {"target":"Spawning Vats (E2M7) Red Exit","pro":False}]}, {"name":"Spawning Vats (E2M7) Entrance Secret", "connects_to_hub":False, "episode":2, - "connections":["Spawning Vats (E2M7) Main"]}, + "connections":[{"target":"Spawning Vats (E2M7) Main","pro":False}]}, + {"name":"Spawning Vats (E2M7) Red Exit", + "connects_to_hub":False, + "episode":2, + "connections":[]}, # Tower of Babel (E2M8) {"name":"Tower of Babel (E2M8) Main", @@ -295,134 +323,134 @@ regions:List[RegionDict] = [ "connects_to_hub":True, "episode":2, "connections":[ - "Fortress of Mystery (E2M9) Blue", - "Fortress of Mystery (E2M9) Red", - "Fortress of Mystery (E2M9) Yellow"]}, + {"target":"Fortress of Mystery (E2M9) Blue","pro":False}, + {"target":"Fortress of Mystery (E2M9) Red","pro":False}, + {"target":"Fortress of Mystery (E2M9) Yellow","pro":False}]}, {"name":"Fortress of Mystery (E2M9) Blue", "connects_to_hub":False, "episode":2, - "connections":["Fortress of Mystery (E2M9) Main"]}, + "connections":[{"target":"Fortress of Mystery (E2M9) Main","pro":False}]}, {"name":"Fortress of Mystery (E2M9) Red", "connects_to_hub":False, "episode":2, - "connections":["Fortress of Mystery (E2M9) Main"]}, + "connections":[{"target":"Fortress of Mystery (E2M9) Main","pro":False}]}, {"name":"Fortress of Mystery (E2M9) Yellow", "connects_to_hub":False, "episode":2, - "connections":["Fortress of Mystery (E2M9) Main"]}, + "connections":[{"target":"Fortress of Mystery (E2M9) Main","pro":False}]}, # Hell Keep (E3M1) {"name":"Hell Keep (E3M1) Main", "connects_to_hub":True, "episode":3, - "connections":["Hell Keep (E3M1) Narrow"]}, + "connections":[{"target":"Hell Keep (E3M1) Narrow","pro":False}]}, {"name":"Hell Keep (E3M1) Narrow", "connects_to_hub":False, "episode":3, - "connections":["Hell Keep (E3M1) Main"]}, + "connections":[{"target":"Hell Keep (E3M1) Main","pro":False}]}, # Slough of Despair (E3M2) {"name":"Slough of Despair (E3M2) Main", "connects_to_hub":True, "episode":3, - "connections":["Slough of Despair (E3M2) Blue"]}, + "connections":[{"target":"Slough of Despair (E3M2) Blue","pro":False}]}, {"name":"Slough of Despair (E3M2) Blue", "connects_to_hub":False, "episode":3, - "connections":["Slough of Despair (E3M2) Main"]}, + "connections":[{"target":"Slough of Despair (E3M2) Main","pro":False}]}, # Pandemonium (E3M3) {"name":"Pandemonium (E3M3) Main", "connects_to_hub":True, "episode":3, - "connections":["Pandemonium (E3M3) Blue"]}, + "connections":[{"target":"Pandemonium (E3M3) Blue","pro":False}]}, {"name":"Pandemonium (E3M3) Blue", "connects_to_hub":False, "episode":3, - "connections":["Pandemonium (E3M3) Main"]}, + "connections":[{"target":"Pandemonium (E3M3) Main","pro":False}]}, # House of Pain (E3M4) {"name":"House of Pain (E3M4) Main", "connects_to_hub":True, "episode":3, - "connections":["House of Pain (E3M4) Blue"]}, + "connections":[{"target":"House of Pain (E3M4) Blue","pro":False}]}, {"name":"House of Pain (E3M4) Blue", "connects_to_hub":False, "episode":3, "connections":[ - "House of Pain (E3M4) Main", - "House of Pain (E3M4) Yellow", - "House of Pain (E3M4) Red"]}, + {"target":"House of Pain (E3M4) Main","pro":False}, + {"target":"House of Pain (E3M4) Yellow","pro":False}, + {"target":"House of Pain (E3M4) Red","pro":False}]}, {"name":"House of Pain (E3M4) Red", "connects_to_hub":False, "episode":3, - "connections":["House of Pain (E3M4) Blue"]}, + "connections":[{"target":"House of Pain (E3M4) Blue","pro":False}]}, {"name":"House of Pain (E3M4) Yellow", "connects_to_hub":False, "episode":3, - "connections":["House of Pain (E3M4) Blue"]}, + "connections":[{"target":"House of Pain (E3M4) Blue","pro":False}]}, # Unholy Cathedral (E3M5) {"name":"Unholy Cathedral (E3M5) Main", "connects_to_hub":True, "episode":3, "connections":[ - "Unholy Cathedral (E3M5) Yellow", - "Unholy Cathedral (E3M5) Blue"]}, + {"target":"Unholy Cathedral (E3M5) Yellow","pro":False}, + {"target":"Unholy Cathedral (E3M5) Blue","pro":False}]}, {"name":"Unholy Cathedral (E3M5) Blue", "connects_to_hub":False, "episode":3, - "connections":["Unholy Cathedral (E3M5) Main"]}, + "connections":[{"target":"Unholy Cathedral (E3M5) Main","pro":False}]}, {"name":"Unholy Cathedral (E3M5) Yellow", "connects_to_hub":False, "episode":3, - "connections":["Unholy Cathedral (E3M5) Main"]}, + "connections":[{"target":"Unholy Cathedral (E3M5) Main","pro":False}]}, # Mt. Erebus (E3M6) {"name":"Mt. Erebus (E3M6) Main", "connects_to_hub":True, "episode":3, - "connections":["Mt. Erebus (E3M6) Blue"]}, + "connections":[{"target":"Mt. Erebus (E3M6) Blue","pro":False}]}, {"name":"Mt. Erebus (E3M6) Blue", "connects_to_hub":False, "episode":3, - "connections":["Mt. Erebus (E3M6) Main"]}, + "connections":[{"target":"Mt. Erebus (E3M6) Main","pro":False}]}, # Limbo (E3M7) {"name":"Limbo (E3M7) Main", "connects_to_hub":True, "episode":3, "connections":[ - "Limbo (E3M7) Red", - "Limbo (E3M7) Blue", - "Limbo (E3M7) Pink"]}, + {"target":"Limbo (E3M7) Red","pro":False}, + {"target":"Limbo (E3M7) Blue","pro":False}, + {"target":"Limbo (E3M7) Pink","pro":False}]}, {"name":"Limbo (E3M7) Blue", "connects_to_hub":False, "episode":3, - "connections":["Limbo (E3M7) Main"]}, + "connections":[{"target":"Limbo (E3M7) Main","pro":False}]}, {"name":"Limbo (E3M7) Red", "connects_to_hub":False, "episode":3, "connections":[ - "Limbo (E3M7) Main", - "Limbo (E3M7) Yellow", - "Limbo (E3M7) Green"]}, + {"target":"Limbo (E3M7) Main","pro":False}, + {"target":"Limbo (E3M7) Yellow","pro":False}, + {"target":"Limbo (E3M7) Green","pro":False}]}, {"name":"Limbo (E3M7) Yellow", "connects_to_hub":False, "episode":3, - "connections":["Limbo (E3M7) Red"]}, + "connections":[{"target":"Limbo (E3M7) Red","pro":False}]}, {"name":"Limbo (E3M7) Pink", "connects_to_hub":False, "episode":3, "connections":[ - "Limbo (E3M7) Green", - "Limbo (E3M7) Main"]}, + {"target":"Limbo (E3M7) Green","pro":False}, + {"target":"Limbo (E3M7) Main","pro":False}]}, {"name":"Limbo (E3M7) Green", "connects_to_hub":False, "episode":3, "connections":[ - "Limbo (E3M7) Pink", - "Limbo (E3M7) Red"]}, + {"target":"Limbo (E3M7) Pink","pro":False}, + {"target":"Limbo (E3M7) Red","pro":False}]}, # Dis (E3M8) {"name":"Dis (E3M8) Main", @@ -435,8 +463,8 @@ regions:List[RegionDict] = [ "connects_to_hub":True, "episode":3, "connections":[ - "Warrens (E3M9) Blue", - "Warrens (E3M9) Blue trigger"]}, + {"target":"Warrens (E3M9) Blue","pro":False}, + {"target":"Warrens (E3M9) Blue trigger","pro":False}]}, {"name":"Warrens (E3M9) Red", "connects_to_hub":False, "episode":3, @@ -445,8 +473,8 @@ regions:List[RegionDict] = [ "connects_to_hub":False, "episode":3, "connections":[ - "Warrens (E3M9) Main", - "Warrens (E3M9) Red"]}, + {"target":"Warrens (E3M9) Main","pro":False}, + {"target":"Warrens (E3M9) Red","pro":False}]}, {"name":"Warrens (E3M9) Blue trigger", "connects_to_hub":False, "episode":3, @@ -457,36 +485,36 @@ regions:List[RegionDict] = [ "connects_to_hub":True, "episode":4, "connections":[ - "Hell Beneath (E4M1) Red", - "Hell Beneath (E4M1) Blue"]}, + {"target":"Hell Beneath (E4M1) Red","pro":False}, + {"target":"Hell Beneath (E4M1) Blue","pro":False}]}, {"name":"Hell Beneath (E4M1) Red", "connects_to_hub":False, "episode":4, - "connections":["Hell Beneath (E4M1) Main"]}, + "connections":[{"target":"Hell Beneath (E4M1) Main","pro":False}]}, {"name":"Hell Beneath (E4M1) Blue", "connects_to_hub":False, "episode":4, - "connections":["Hell Beneath (E4M1) Main"]}, + "connections":[{"target":"Hell Beneath (E4M1) Main","pro":False}]}, # Perfect Hatred (E4M2) {"name":"Perfect Hatred (E4M2) Main", "connects_to_hub":True, "episode":4, "connections":[ - "Perfect Hatred (E4M2) Blue", - "Perfect Hatred (E4M2) Yellow"]}, + {"target":"Perfect Hatred (E4M2) Blue","pro":False}, + {"target":"Perfect Hatred (E4M2) Yellow","pro":False}]}, {"name":"Perfect Hatred (E4M2) Blue", "connects_to_hub":False, "episode":4, "connections":[ - "Perfect Hatred (E4M2) Main", - "Perfect Hatred (E4M2) Cave"]}, + {"target":"Perfect Hatred (E4M2) Main","pro":False}, + {"target":"Perfect Hatred (E4M2) Cave","pro":False}]}, {"name":"Perfect Hatred (E4M2) Yellow", "connects_to_hub":False, "episode":4, "connections":[ - "Perfect Hatred (E4M2) Main", - "Perfect Hatred (E4M2) Cave"]}, + {"target":"Perfect Hatred (E4M2) Main","pro":False}, + {"target":"Perfect Hatred (E4M2) Cave","pro":False}]}, {"name":"Perfect Hatred (E4M2) Cave", "connects_to_hub":False, "episode":4, @@ -496,132 +524,135 @@ regions:List[RegionDict] = [ {"name":"Sever the Wicked (E4M3) Main", "connects_to_hub":True, "episode":4, - "connections":["Sever the Wicked (E4M3) Red"]}, + "connections":[{"target":"Sever the Wicked (E4M3) Red","pro":False}]}, {"name":"Sever the Wicked (E4M3) Red", "connects_to_hub":False, "episode":4, "connections":[ - "Sever the Wicked (E4M3) Blue", - "Sever the Wicked (E4M3) Main"]}, + {"target":"Sever the Wicked (E4M3) Blue","pro":False}, + {"target":"Sever the Wicked (E4M3) Main","pro":False}]}, {"name":"Sever the Wicked (E4M3) Blue", "connects_to_hub":False, "episode":4, - "connections":["Sever the Wicked (E4M3) Red"]}, + "connections":[{"target":"Sever the Wicked (E4M3) Red","pro":False}]}, # Unruly Evil (E4M4) {"name":"Unruly Evil (E4M4) Main", "connects_to_hub":True, "episode":4, - "connections":["Unruly Evil (E4M4) Red"]}, + "connections":[{"target":"Unruly Evil (E4M4) Red","pro":False}]}, {"name":"Unruly Evil (E4M4) Red", "connects_to_hub":False, "episode":4, - "connections":["Unruly Evil (E4M4) Main"]}, + "connections":[{"target":"Unruly Evil (E4M4) Main","pro":False}]}, # They Will Repent (E4M5) {"name":"They Will Repent (E4M5) Main", "connects_to_hub":True, "episode":4, - "connections":["They Will Repent (E4M5) Red"]}, + "connections":[{"target":"They Will Repent (E4M5) Red","pro":False}]}, {"name":"They Will Repent (E4M5) Yellow", "connects_to_hub":False, "episode":4, - "connections":["They Will Repent (E4M5) Red"]}, + "connections":[{"target":"They Will Repent (E4M5) Red","pro":False}]}, {"name":"They Will Repent (E4M5) Blue", "connects_to_hub":False, "episode":4, - "connections":["They Will Repent (E4M5) Red"]}, + "connections":[{"target":"They Will Repent (E4M5) Red","pro":False}]}, {"name":"They Will Repent (E4M5) Red", "connects_to_hub":False, "episode":4, "connections":[ - "They Will Repent (E4M5) Main", - "They Will Repent (E4M5) Yellow", - "They Will Repent (E4M5) Blue"]}, + {"target":"They Will Repent (E4M5) Main","pro":False}, + {"target":"They Will Repent (E4M5) Yellow","pro":False}, + {"target":"They Will Repent (E4M5) Blue","pro":False}]}, # Against Thee Wickedly (E4M6) {"name":"Against Thee Wickedly (E4M6) Main", "connects_to_hub":True, "episode":4, - "connections":["Against Thee Wickedly (E4M6) Blue"]}, + "connections":[ + {"target":"Against Thee Wickedly (E4M6) Blue","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Pink","pro":True}]}, {"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"]}, + {"target":"Against Thee Wickedly (E4M6) Blue","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Pink","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Main","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Magenta","pro":True}]}, {"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"]}, + {"target":"Against Thee Wickedly (E4M6) Main","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Yellow","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Red","pro":False}]}, {"name":"Against Thee Wickedly (E4M6) Magenta", "connects_to_hub":False, "episode":4, - "connections":["Against Thee Wickedly (E4M6) Main"]}, + "connections":[{"target":"Against Thee Wickedly (E4M6) Main","pro":False}]}, {"name":"Against Thee Wickedly (E4M6) Yellow", "connects_to_hub":False, "episode":4, "connections":[ - "Against Thee Wickedly (E4M6) Blue", - "Against Thee Wickedly (E4M6) Magenta"]}, + {"target":"Against Thee Wickedly (E4M6) Blue","pro":False}, + {"target":"Against Thee Wickedly (E4M6) Magenta","pro":False}]}, {"name":"Against Thee Wickedly (E4M6) Pink", "connects_to_hub":False, "episode":4, - "connections":["Against Thee Wickedly (E4M6) Main"]}, + "connections":[{"target":"Against Thee Wickedly (E4M6) Main","pro":False}]}, # And Hell Followed (E4M7) {"name":"And Hell Followed (E4M7) Main", "connects_to_hub":True, "episode":4, "connections":[ - "And Hell Followed (E4M7) Blue", - "And Hell Followed (E4M7) Red", - "And Hell Followed (E4M7) Yellow"]}, + {"target":"And Hell Followed (E4M7) Blue","pro":False}, + {"target":"And Hell Followed (E4M7) Red","pro":False}, + {"target":"And Hell Followed (E4M7) Yellow","pro":False}]}, {"name":"And Hell Followed (E4M7) Red", "connects_to_hub":False, "episode":4, - "connections":["And Hell Followed (E4M7) Main"]}, + "connections":[{"target":"And Hell Followed (E4M7) Main","pro":False}]}, {"name":"And Hell Followed (E4M7) Blue", "connects_to_hub":False, "episode":4, - "connections":["And Hell Followed (E4M7) Main"]}, + "connections":[{"target":"And Hell Followed (E4M7) Main","pro":False}]}, {"name":"And Hell Followed (E4M7) Yellow", "connects_to_hub":False, "episode":4, - "connections":["And Hell Followed (E4M7) Main"]}, + "connections":[{"target":"And Hell Followed (E4M7) Main","pro":False}]}, # Unto the Cruel (E4M8) {"name":"Unto the Cruel (E4M8) Main", "connects_to_hub":True, "episode":4, "connections":[ - "Unto the Cruel (E4M8) Red", - "Unto the Cruel (E4M8) Yellow", - "Unto the Cruel (E4M8) Orange"]}, + {"target":"Unto the Cruel (E4M8) Red","pro":False}, + {"target":"Unto the Cruel (E4M8) Yellow","pro":False}, + {"target":"Unto the Cruel (E4M8) Orange","pro":False}]}, {"name":"Unto the Cruel (E4M8) Yellow", "connects_to_hub":False, "episode":4, - "connections":["Unto the Cruel (E4M8) Main"]}, + "connections":[{"target":"Unto the Cruel (E4M8) Main","pro":False}]}, {"name":"Unto the Cruel (E4M8) Red", "connects_to_hub":False, "episode":4, - "connections":["Unto the Cruel (E4M8) Main"]}, + "connections":[{"target":"Unto the Cruel (E4M8) Main","pro":False}]}, {"name":"Unto the Cruel (E4M8) Orange", "connects_to_hub":False, "episode":4, - "connections":["Unto the Cruel (E4M8) Main"]}, + "connections":[{"target":"Unto the Cruel (E4M8) Main","pro":False}]}, # Fear (E4M9) {"name":"Fear (E4M9) Main", "connects_to_hub":True, "episode":4, - "connections":["Fear (E4M9) Yellow"]}, + "connections":[{"target":"Fear (E4M9) Yellow","pro":False}]}, {"name":"Fear (E4M9) Yellow", "connects_to_hub":False, "episode":4, - "connections":["Fear (E4M9) Main"]}, + "connections":[{"target":"Fear (E4M9) Main","pro":False}]}, ] diff --git a/worlds/doom_1993/Rules.py b/worlds/doom_1993/Rules.py index 6e13a8af34..d5abc367a1 100644 --- a/worlds/doom_1993/Rules.py +++ b/worlds/doom_1993/Rules.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from . import DOOM1993World -def set_episode1_rules(player, world): +def set_episode1_rules(player, world, pro): # Hangar (E1M1) set_rule(world.get_entrance("Hub -> Hangar (E1M1) Main", player), lambda state: state.has("Hangar (E1M1)", player, 1)) @@ -130,7 +130,7 @@ def set_episode1_rules(player, world): state.has("Military Base (E1M9) - Yellow keycard", player, 1)) -def set_episode2_rules(player, world): +def set_episode2_rules(player, world, pro): # Deimos Anomaly (E2M1) set_rule(world.get_entrance("Hub -> Deimos Anomaly (E2M1) Main", player), lambda state: state.has("Deimos Anomaly (E2M1)", player, 1)) @@ -226,6 +226,9 @@ def set_episode2_rules(player, world): state.has("Spawning Vats (E2M7) - Red keycard", player, 1)) set_rule(world.get_entrance("Spawning Vats (E2M7) Main -> Spawning Vats (E2M7) Yellow", player), lambda state: state.has("Spawning Vats (E2M7) - Yellow keycard", player, 1)) + if pro: + set_rule(world.get_entrance("Spawning Vats (E2M7) Main -> Spawning Vats (E2M7) Red Exit", player), lambda state: + state.has("Rocket launcher", player, 1)) set_rule(world.get_entrance("Spawning Vats (E2M7) Yellow -> Spawning Vats (E2M7) Main", player), lambda state: state.has("Spawning Vats (E2M7) - Yellow keycard", player, 1)) set_rule(world.get_entrance("Spawning Vats (E2M7) Red -> Spawning Vats (E2M7) Main", player), lambda state: @@ -260,7 +263,7 @@ def set_episode2_rules(player, world): state.has("Fortress of Mystery (E2M9) - Yellow skull key", player, 1)) -def set_episode3_rules(player, world): +def set_episode3_rules(player, world, pro): # Hell Keep (E3M1) set_rule(world.get_entrance("Hub -> Hell Keep (E3M1) Main", player), lambda state: state.has("Hell Keep (E3M1)", player, 1)) @@ -385,7 +388,7 @@ def set_episode3_rules(player, world): state.has("Warrens (E3M9) - Red skull key", player, 1)) -def set_episode4_rules(player, world): +def set_episode4_rules(player, world, pro): # Hell Beneath (E4M1) set_rule(world.get_entrance("Hub -> Hell Beneath (E4M1) Main", player), lambda state: state.has("Hell Beneath (E4M1)", player, 1)) @@ -520,15 +523,15 @@ def set_episode4_rules(player, world): state.has("Fear (E4M9) - Yellow skull key", player, 1)) -def set_rules(doom_1993_world: "DOOM1993World", included_episodes): +def set_rules(doom_1993_world: "DOOM1993World", included_episodes, pro): player = doom_1993_world.player world = doom_1993_world.multiworld if included_episodes[0]: - set_episode1_rules(player, world) + set_episode1_rules(player, world, pro) if included_episodes[1]: - set_episode2_rules(player, world) + set_episode2_rules(player, world, pro) if included_episodes[2]: - set_episode3_rules(player, world) + set_episode3_rules(player, world, pro) if included_episodes[3]: - set_episode4_rules(player, world) + set_episode4_rules(player, world, pro) diff --git a/worlds/doom_1993/__init__.py b/worlds/doom_1993/__init__.py index 83a8652af1..e420b34b4f 100644 --- a/worlds/doom_1993/__init__.py +++ b/worlds/doom_1993/__init__.py @@ -56,6 +56,13 @@ class DOOM1993World(World): "Hell Beneath (E4M1)" ] + boss_level_for_espidoes: List[str] = [ + "Phobos Anomaly (E1M8)", + "Tower of Babel (E2M8)", + "Dis (E3M8)", + "Unto the Cruel (E4M8)" + ] + # Item ratio that scales depending on episode count. These are the ratio for 3 episode. items_ratio: Dict[str, float] = { "Armor": 41, @@ -90,6 +97,8 @@ class DOOM1993World(World): self.included_episodes[0] = 1 def create_regions(self): + pro = getattr(self.multiworld, "pro")[self.player].value + # Main regions menu_region = Region("Menu", self.player, self.multiworld) hub_region = Region("Hub", self.player, self.multiworld) @@ -116,8 +125,11 @@ class DOOM1993World(World): self.multiworld.regions.append(region) - for connection in region_dict["connections"]: - connections.append((region, connection)) + for connection_dict in region_dict["connections"]: + # Check if it's a pro-only connection + if connection_dict["pro"] and not pro: + continue + connections.append((region, connection_dict["target"])) # Connect main regions to Hub hub_region.add_exits(main_regions) @@ -135,7 +147,11 @@ class DOOM1993World(World): self.location_count = len(self.multiworld.get_locations(self.player)) def completion_rule(self, state: CollectionState): - for map_name in Maps.map_names: + goal_levels = Maps.map_names + if getattr(self.multiworld, "goal")[self.player].value: + goal_levels = self.boss_level_for_espidoes + + for map_name in goal_levels: if map_name + " - Exit" not in self.location_name_to_id: continue @@ -151,12 +167,15 @@ class DOOM1993World(World): return True def set_rules(self): - Rules.set_rules(self, self.included_episodes) + pro = getattr(self.multiworld, "pro")[self.player].value + allow_death_logic = getattr(self.multiworld, "allow_death_logic")[self.player].value + + Rules.set_rules(self, self.included_episodes, pro) self.multiworld.completion_condition[self.player] = lambda state: self.completion_rule(state) # Forbid progression items to locations that can be missed and can't be picked up. (e.g. One-time timed # platform) Unless the user allows for it. - if not getattr(self.multiworld, "allow_death_logic")[self.player].value: + if not allow_death_logic: for death_logic_location in Locations.death_logic_locations: self.multiworld.exclude_locations[self.player].value.add(death_logic_location) @@ -165,7 +184,6 @@ class DOOM1993World(World): return DOOM1993Item(name, Items.item_table[item_id]["classification"], item_id, self.player) def create_items(self): - is_only_first_episode: bool = self.get_episode_count() == 1 and self.included_episodes[0] itempool: List[DOOM1993Item] = [] start_with_computer_area_maps: bool = getattr(self.multiworld, "start_with_computer_area_maps")[self.player].value @@ -180,9 +198,6 @@ class DOOM1993World(World): if item["episode"] != -1 and not self.included_episodes[item["episode"] - 1]: continue - if item["name"] in {"BFG9000", "Plasma Gun"} and is_only_first_episode: - continue # Don't include those guns if only first episode - count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1 itempool += [self.create_item(item["name"]) for _ in range(count)] @@ -212,8 +227,10 @@ class DOOM1993World(World): # Give Computer area maps if option selected if getattr(self.multiworld, "start_with_computer_area_maps")[self.player].value: for item_id, item_dict in Items.item_table.items(): - if item_dict["doom_type"] == DOOM_TYPE_COMPUTER_AREA_MAP: - self.multiworld.push_precollected(self.create_item(item_dict["name"])) + item_episode = item_dict["episode"] + if item_episode > 0: + if item_dict["doom_type"] == DOOM_TYPE_COMPUTER_AREA_MAP and self.included_episodes[item_episode - 1]: + self.multiworld.push_precollected(self.create_item(item_dict["name"])) # Fill the rest starting with powerups, then fillers self.create_ratioed_items("Armor", itempool) diff --git a/worlds/doom_1993/docs/setup_en.md b/worlds/doom_1993/docs/setup_en.md index cfd97f623a..1e546d359c 100644 --- a/worlds/doom_1993/docs/setup_en.md +++ b/worlds/doom_1993/docs/setup_en.md @@ -8,6 +8,8 @@ ## Optional Software - [ArchipelagoTextClient](https://github.com/ArchipelagoMW/Archipelago/releases) +- [PopTracker](https://github.com/black-sliver/PopTracker/) + - [OZone's APDoom tracker pack](https://github.com/Ozone31/doom-ap-tracker/releases) ## Installing AP Doom 1. Download [APDOOM.zip](https://github.com/Daivuk/apdoom/releases) and extract it. @@ -17,10 +19,11 @@ ## Joining a MultiWorld Game -1. Launch APDoomLauncher.exe -2. Enter the Archipelago server address, slot name, and password (if you have one) -3. Press "Launch DOOM" -4. Enjoy! +1. Launch apdoom-launcher.exe +2. Select `Ultimate DOOM` from the drop-down +3. Enter the Archipelago server address, slot name, and password (if you have one) +4. Press "Launch DOOM" +5. Enjoy! To continue a game, follow the same connection steps. Connecting with a different seed won't erase your progress in other seeds. @@ -31,8 +34,23 @@ We recommend having Archipelago's Text Client open on the side to keep track of APDOOM has in-game messages, but they disappear quickly and there's no reasonable way to check your message history in-game. +### Hinting + +To hint from in-game, use the chat (Default key: 'T'). Hinting from DOOM can be difficult because names are rather long and contain special characters. For example: +``` +!hint Toxin Refinery (E1M3) - Computer area map +``` +The game has a hint helper implemented, where you can simply type this: +``` +!hint e1m3 map +``` +For this to work, include the map short name (`E1M1`), followed by one of the keywords: `map`, `blue`, `yellow`, `red`. + ## Auto-Tracking APDOOM has a functional map tracker integrated into the level select screen. It tells you which levels you have unlocked, which keys you have for each level, which levels have been completed, and how many of the checks you have completed in each level. + +For better tracking, try OZone's poptracker package: https://github.com/Ozone31/doom-ap-tracker/releases . +Requires [PopTracker](https://github.com/black-sliver/PopTracker/). diff --git a/worlds/doom_ii/Items.py b/worlds/doom_ii/Items.py new file mode 100644 index 0000000000..fc426cc883 --- /dev/null +++ b/worlds/doom_ii/Items.py @@ -0,0 +1,1071 @@ +# This file is auto generated. More info: https://github.com/Daivuk/apdoom + +from BaseClasses import ItemClassification +from typing import TypedDict, Dict, Set + + +class ItemDict(TypedDict, total=False): + classification: ItemClassification + count: int + name: str + doom_type: int # Unique numerical id used to spawn the item. -1 is level item, -2 is level complete item. + episode: int # Relevant if that item targets a specific level, like keycard or map reveal pickup. + map: int + + +item_table: Dict[int, ItemDict] = { + 360000: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Shotgun', + 'doom_type': 2001, + 'episode': -1, + 'map': -1}, + 360001: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Rocket launcher', + 'doom_type': 2003, + 'episode': -1, + 'map': -1}, + 360002: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Plasma gun', + 'doom_type': 2004, + 'episode': -1, + 'map': -1}, + 360003: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Chainsaw', + 'doom_type': 2005, + 'episode': -1, + 'map': -1}, + 360004: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Chaingun', + 'doom_type': 2002, + 'episode': -1, + 'map': -1}, + 360005: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'BFG9000', + 'doom_type': 2006, + 'episode': -1, + 'map': -1}, + 360006: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Super Shotgun', + 'doom_type': 82, + 'episode': -1, + 'map': -1}, + 360007: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Backpack', + 'doom_type': 8, + 'episode': -1, + 'map': -1}, + 360008: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Armor', + 'doom_type': 2018, + 'episode': -1, + 'map': -1}, + 360009: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Mega Armor', + 'doom_type': 2019, + 'episode': -1, + 'map': -1}, + 360010: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Berserk', + 'doom_type': 2023, + 'episode': -1, + 'map': -1}, + 360011: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Invulnerability', + 'doom_type': 2022, + 'episode': -1, + 'map': -1}, + 360012: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Partial invisibility', + 'doom_type': 2024, + 'episode': -1, + 'map': -1}, + 360013: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Supercharge', + 'doom_type': 2013, + 'episode': -1, + 'map': -1}, + 360014: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Megasphere', + 'doom_type': 83, + 'episode': -1, + 'map': -1}, + 360015: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Medikit', + 'doom_type': 2012, + 'episode': -1, + 'map': -1}, + 360016: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Box of bullets', + 'doom_type': 2048, + 'episode': -1, + 'map': -1}, + 360017: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Box of rockets', + 'doom_type': 2046, + 'episode': -1, + 'map': -1}, + 360018: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Box of shotgun shells', + 'doom_type': 2049, + 'episode': -1, + 'map': -1}, + 360019: {'classification': ItemClassification.filler, + 'count': 0, + 'name': 'Energy cell pack', + 'doom_type': 17, + 'episode': -1, + 'map': -1}, + 360200: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Underhalls (MAP02) - Red keycard', + 'doom_type': 13, + 'episode': 1, + 'map': 2}, + 360201: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Underhalls (MAP02) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 2}, + 360202: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Gantlet (MAP03) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 3}, + 360203: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Gantlet (MAP03) - Red keycard', + 'doom_type': 13, + 'episode': 1, + 'map': 3}, + 360204: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Focus (MAP04) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 4}, + 360205: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Focus (MAP04) - Red keycard', + 'doom_type': 13, + 'episode': 1, + 'map': 4}, + 360206: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Focus (MAP04) - Yellow keycard', + 'doom_type': 6, + 'episode': 1, + 'map': 4}, + 360207: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Waste Tunnels (MAP05) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 5}, + 360208: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Waste Tunnels (MAP05) - Red keycard', + 'doom_type': 13, + 'episode': 1, + 'map': 5}, + 360209: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Waste Tunnels (MAP05) - Yellow keycard', + 'doom_type': 6, + 'episode': 1, + 'map': 5}, + 360210: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Crusher (MAP06) - Red keycard', + 'doom_type': 13, + 'episode': 1, + 'map': 6}, + 360211: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Crusher (MAP06) - Yellow keycard', + 'doom_type': 6, + 'episode': 1, + 'map': 6}, + 360212: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Crusher (MAP06) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 6}, + 360213: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tricks and Traps (MAP08) - Yellow skull key', + 'doom_type': 39, + 'episode': 1, + 'map': 8}, + 360214: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tricks and Traps (MAP08) - Red skull key', + 'doom_type': 38, + 'episode': 1, + 'map': 8}, + 360215: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Pit (MAP09) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 9}, + 360216: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Pit (MAP09) - Yellow keycard', + 'doom_type': 6, + 'episode': 1, + 'map': 9}, + 360217: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Refueling Base (MAP10) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 10}, + 360218: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Refueling Base (MAP10) - Yellow keycard', + 'doom_type': 6, + 'episode': 1, + 'map': 10}, + 360219: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Circle of Death (MAP11) - Red keycard', + 'doom_type': 13, + 'episode': 1, + 'map': 11}, + 360220: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Circle of Death (MAP11) - Blue keycard', + 'doom_type': 5, + 'episode': 1, + 'map': 11}, + 360221: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Factory (MAP12) - Blue keycard', + 'doom_type': 5, + 'episode': 2, + 'map': 1}, + 360222: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Factory (MAP12) - Yellow keycard', + 'doom_type': 6, + 'episode': 2, + 'map': 1}, + 360223: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Downtown (MAP13) - Blue keycard', + 'doom_type': 5, + 'episode': 2, + 'map': 2}, + 360224: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Downtown (MAP13) - Yellow keycard', + 'doom_type': 6, + 'episode': 2, + 'map': 2}, + 360225: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Downtown (MAP13) - Red keycard', + 'doom_type': 13, + 'episode': 2, + 'map': 2}, + 360226: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Inmost Dens (MAP14) - Red skull key', + 'doom_type': 38, + 'episode': 2, + 'map': 3}, + 360227: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Inmost Dens (MAP14) - Blue skull key', + 'doom_type': 40, + 'episode': 2, + 'map': 3}, + 360228: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Industrial Zone (MAP15) - Yellow keycard', + 'doom_type': 6, + 'episode': 2, + 'map': 4}, + 360229: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Industrial Zone (MAP15) - Red keycard', + 'doom_type': 13, + 'episode': 2, + 'map': 4}, + 360230: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Industrial Zone (MAP15) - Blue keycard', + 'doom_type': 5, + 'episode': 2, + 'map': 4}, + 360231: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Suburbs (MAP16) - Blue skull key', + 'doom_type': 40, + 'episode': 2, + 'map': 5}, + 360232: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Suburbs (MAP16) - Red skull key', + 'doom_type': 38, + 'episode': 2, + 'map': 5}, + 360233: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tenements (MAP17) - Red keycard', + 'doom_type': 13, + 'episode': 2, + 'map': 6}, + 360234: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tenements (MAP17) - Blue keycard', + 'doom_type': 5, + 'episode': 2, + 'map': 6}, + 360235: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tenements (MAP17) - Yellow skull key', + 'doom_type': 39, + 'episode': 2, + 'map': 6}, + 360236: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Courtyard (MAP18) - Yellow skull key', + 'doom_type': 39, + 'episode': 2, + 'map': 7}, + 360237: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Courtyard (MAP18) - Blue skull key', + 'doom_type': 40, + 'episode': 2, + 'map': 7}, + 360238: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Citadel (MAP19) - Blue skull key', + 'doom_type': 40, + 'episode': 2, + 'map': 8}, + 360239: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Citadel (MAP19) - Red skull key', + 'doom_type': 38, + 'episode': 2, + 'map': 8}, + 360240: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Citadel (MAP19) - Yellow skull key', + 'doom_type': 39, + 'episode': 2, + 'map': 8}, + 360241: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Nirvana (MAP21) - Yellow skull key', + 'doom_type': 39, + 'episode': 3, + 'map': 1}, + 360242: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Nirvana (MAP21) - Blue skull key', + 'doom_type': 40, + 'episode': 3, + 'map': 1}, + 360243: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Nirvana (MAP21) - Red skull key', + 'doom_type': 38, + 'episode': 3, + 'map': 1}, + 360244: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Catacombs (MAP22) - Blue skull key', + 'doom_type': 40, + 'episode': 3, + 'map': 2}, + 360245: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Catacombs (MAP22) - Red skull key', + 'doom_type': 38, + 'episode': 3, + 'map': 2}, + 360246: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Barrels o Fun (MAP23) - Yellow skull key', + 'doom_type': 39, + 'episode': 3, + 'map': 3}, + 360247: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Chasm (MAP24) - Blue keycard', + 'doom_type': 5, + 'episode': 3, + 'map': 4}, + 360248: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Chasm (MAP24) - Red keycard', + 'doom_type': 13, + 'episode': 3, + 'map': 4}, + 360249: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Bloodfalls (MAP25) - Blue skull key', + 'doom_type': 40, + 'episode': 3, + 'map': 5}, + 360250: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Abandoned Mines (MAP26) - Blue keycard', + 'doom_type': 5, + 'episode': 3, + 'map': 6}, + 360251: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Abandoned Mines (MAP26) - Red keycard', + 'doom_type': 13, + 'episode': 3, + 'map': 6}, + 360252: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Abandoned Mines (MAP26) - Yellow keycard', + 'doom_type': 6, + 'episode': 3, + 'map': 6}, + 360253: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Monster Condo (MAP27) - Yellow skull key', + 'doom_type': 39, + 'episode': 3, + 'map': 7}, + 360254: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Monster Condo (MAP27) - Red skull key', + 'doom_type': 38, + 'episode': 3, + 'map': 7}, + 360255: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Monster Condo (MAP27) - Blue skull key', + 'doom_type': 40, + 'episode': 3, + 'map': 7}, + 360256: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Spirit World (MAP28) - Yellow skull key', + 'doom_type': 39, + 'episode': 3, + 'map': 8}, + 360257: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Spirit World (MAP28) - Red skull key', + 'doom_type': 38, + 'episode': 3, + 'map': 8}, + 360400: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Entryway (MAP01)', + 'doom_type': -1, + 'episode': 1, + 'map': 1}, + 360401: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Entryway (MAP01) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 1}, + 360402: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Entryway (MAP01) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 1}, + 360403: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Underhalls (MAP02)', + 'doom_type': -1, + 'episode': 1, + 'map': 2}, + 360404: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Underhalls (MAP02) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 2}, + 360405: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Underhalls (MAP02) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 2}, + 360406: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Gantlet (MAP03)', + 'doom_type': -1, + 'episode': 1, + 'map': 3}, + 360407: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Gantlet (MAP03) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 3}, + 360408: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Gantlet (MAP03) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 3}, + 360409: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Focus (MAP04)', + 'doom_type': -1, + 'episode': 1, + 'map': 4}, + 360410: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Focus (MAP04) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 4}, + 360411: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Focus (MAP04) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 4}, + 360412: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Waste Tunnels (MAP05)', + 'doom_type': -1, + 'episode': 1, + 'map': 5}, + 360413: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Waste Tunnels (MAP05) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 5}, + 360414: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Waste Tunnels (MAP05) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 5}, + 360415: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Crusher (MAP06)', + 'doom_type': -1, + 'episode': 1, + 'map': 6}, + 360416: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Crusher (MAP06) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 6}, + 360417: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Crusher (MAP06) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 6}, + 360418: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Dead Simple (MAP07)', + 'doom_type': -1, + 'episode': 1, + 'map': 7}, + 360419: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Dead Simple (MAP07) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 7}, + 360420: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Dead Simple (MAP07) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 7}, + 360421: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tricks and Traps (MAP08)', + 'doom_type': -1, + 'episode': 1, + 'map': 8}, + 360422: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tricks and Traps (MAP08) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 8}, + 360423: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Tricks and Traps (MAP08) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 8}, + 360424: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Pit (MAP09)', + 'doom_type': -1, + 'episode': 1, + 'map': 9}, + 360425: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Pit (MAP09) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 9}, + 360426: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Pit (MAP09) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 9}, + 360427: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Refueling Base (MAP10)', + 'doom_type': -1, + 'episode': 1, + 'map': 10}, + 360428: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Refueling Base (MAP10) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 10}, + 360429: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Refueling Base (MAP10) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 10}, + 360430: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Circle of Death (MAP11)', + 'doom_type': -1, + 'episode': 1, + 'map': 11}, + 360431: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Circle of Death (MAP11) - Complete', + 'doom_type': -2, + 'episode': 1, + 'map': 11}, + 360432: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Circle of Death (MAP11) - Computer area map', + 'doom_type': 2026, + 'episode': 1, + 'map': 11}, + 360433: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Factory (MAP12)', + 'doom_type': -1, + 'episode': 2, + 'map': 1}, + 360434: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Factory (MAP12) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 1}, + 360435: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Factory (MAP12) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 1}, + 360436: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Downtown (MAP13)', + 'doom_type': -1, + 'episode': 2, + 'map': 2}, + 360437: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Downtown (MAP13) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 2}, + 360438: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Downtown (MAP13) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 2}, + 360439: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Inmost Dens (MAP14)', + 'doom_type': -1, + 'episode': 2, + 'map': 3}, + 360440: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Inmost Dens (MAP14) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 3}, + 360441: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Inmost Dens (MAP14) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 3}, + 360442: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Industrial Zone (MAP15)', + 'doom_type': -1, + 'episode': 2, + 'map': 4}, + 360443: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Industrial Zone (MAP15) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 4}, + 360444: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Industrial Zone (MAP15) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 4}, + 360445: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Suburbs (MAP16)', + 'doom_type': -1, + 'episode': 2, + 'map': 5}, + 360446: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Suburbs (MAP16) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 5}, + 360447: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Suburbs (MAP16) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 5}, + 360448: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tenements (MAP17)', + 'doom_type': -1, + 'episode': 2, + 'map': 6}, + 360449: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Tenements (MAP17) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 6}, + 360450: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Tenements (MAP17) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 6}, + 360451: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Courtyard (MAP18)', + 'doom_type': -1, + 'episode': 2, + 'map': 7}, + 360452: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Courtyard (MAP18) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 7}, + 360453: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Courtyard (MAP18) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 7}, + 360454: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Citadel (MAP19)', + 'doom_type': -1, + 'episode': 2, + 'map': 8}, + 360455: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Citadel (MAP19) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 8}, + 360456: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Citadel (MAP19) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 8}, + 360457: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Gotcha! (MAP20)', + 'doom_type': -1, + 'episode': 2, + 'map': 9}, + 360458: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Gotcha! (MAP20) - Complete', + 'doom_type': -2, + 'episode': 2, + 'map': 9}, + 360459: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Gotcha! (MAP20) - Computer area map', + 'doom_type': 2026, + 'episode': 2, + 'map': 9}, + 360460: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Nirvana (MAP21)', + 'doom_type': -1, + 'episode': 3, + 'map': 1}, + 360461: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Nirvana (MAP21) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 1}, + 360462: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Nirvana (MAP21) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 1}, + 360463: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Catacombs (MAP22)', + 'doom_type': -1, + 'episode': 3, + 'map': 2}, + 360464: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Catacombs (MAP22) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 2}, + 360465: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Catacombs (MAP22) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 2}, + 360466: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Barrels o Fun (MAP23)', + 'doom_type': -1, + 'episode': 3, + 'map': 3}, + 360467: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Barrels o Fun (MAP23) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 3}, + 360468: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Barrels o Fun (MAP23) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 3}, + 360469: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Chasm (MAP24)', + 'doom_type': -1, + 'episode': 3, + 'map': 4}, + 360470: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Chasm (MAP24) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 4}, + 360471: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Chasm (MAP24) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 4}, + 360472: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Bloodfalls (MAP25)', + 'doom_type': -1, + 'episode': 3, + 'map': 5}, + 360473: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Bloodfalls (MAP25) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 5}, + 360474: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Bloodfalls (MAP25) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 5}, + 360475: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Abandoned Mines (MAP26)', + 'doom_type': -1, + 'episode': 3, + 'map': 6}, + 360476: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Abandoned Mines (MAP26) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 6}, + 360477: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Abandoned Mines (MAP26) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 6}, + 360478: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Monster Condo (MAP27)', + 'doom_type': -1, + 'episode': 3, + 'map': 7}, + 360479: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Monster Condo (MAP27) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 7}, + 360480: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Monster Condo (MAP27) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 7}, + 360481: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Spirit World (MAP28)', + 'doom_type': -1, + 'episode': 3, + 'map': 8}, + 360482: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Spirit World (MAP28) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 8}, + 360483: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Spirit World (MAP28) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 8}, + 360484: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Living End (MAP29)', + 'doom_type': -1, + 'episode': 3, + 'map': 9}, + 360485: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'The Living End (MAP29) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 9}, + 360486: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'The Living End (MAP29) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 9}, + 360487: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Icon of Sin (MAP30)', + 'doom_type': -1, + 'episode': 3, + 'map': 10}, + 360488: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Icon of Sin (MAP30) - Complete', + 'doom_type': -2, + 'episode': 3, + 'map': 10}, + 360489: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Icon of Sin (MAP30) - Computer area map', + 'doom_type': 2026, + 'episode': 3, + 'map': 10}, + 360490: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Wolfenstein2 (MAP31)', + 'doom_type': -1, + 'episode': 4, + 'map': 1}, + 360491: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Wolfenstein2 (MAP31) - Complete', + 'doom_type': -2, + 'episode': 4, + 'map': 1}, + 360492: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Wolfenstein2 (MAP31) - Computer area map', + 'doom_type': 2026, + 'episode': 4, + 'map': 1}, + 360493: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Grosse2 (MAP32)', + 'doom_type': -1, + 'episode': 4, + 'map': 2}, + 360494: {'classification': ItemClassification.progression, + 'count': 1, + 'name': 'Grosse2 (MAP32) - Complete', + 'doom_type': -2, + 'episode': 4, + 'map': 2}, + 360495: {'classification': ItemClassification.filler, + 'count': 1, + 'name': 'Grosse2 (MAP32) - Computer area map', + 'doom_type': 2026, + 'episode': 4, + 'map': 2}, +} + + +item_name_groups: Dict[str, Set[str]] = { + 'Ammos': {'Box of bullets', 'Box of rockets', 'Box of shotgun shells', 'Energy cell pack', }, + 'Computer area maps': {'Barrels o Fun (MAP23) - Computer area map', 'Bloodfalls (MAP25) - Computer area map', 'Circle of Death (MAP11) - Computer area map', 'Dead Simple (MAP07) - Computer area map', 'Downtown (MAP13) - Computer area map', 'Entryway (MAP01) - Computer area map', 'Gotcha! (MAP20) - Computer area map', 'Grosse2 (MAP32) - Computer area map', 'Icon of Sin (MAP30) - Computer area map', 'Industrial Zone (MAP15) - Computer area map', 'Monster Condo (MAP27) - Computer area map', 'Nirvana (MAP21) - Computer area map', 'Refueling Base (MAP10) - Computer area map', 'Suburbs (MAP16) - Computer area map', 'Tenements (MAP17) - Computer area map', 'The Abandoned Mines (MAP26) - Computer area map', 'The Catacombs (MAP22) - Computer area map', 'The Chasm (MAP24) - Computer area map', 'The Citadel (MAP19) - Computer area map', 'The Courtyard (MAP18) - Computer area map', 'The Crusher (MAP06) - Computer area map', 'The Factory (MAP12) - Computer area map', 'The Focus (MAP04) - Computer area map', 'The Gantlet (MAP03) - Computer area map', 'The Inmost Dens (MAP14) - Computer area map', 'The Living End (MAP29) - Computer area map', 'The Pit (MAP09) - Computer area map', 'The Spirit World (MAP28) - Computer area map', 'The Waste Tunnels (MAP05) - Computer area map', 'Tricks and Traps (MAP08) - Computer area map', 'Underhalls (MAP02) - Computer area map', 'Wolfenstein2 (MAP31) - Computer area map', }, + 'Keys': {'Barrels o Fun (MAP23) - Yellow skull key', 'Bloodfalls (MAP25) - Blue skull key', 'Circle of Death (MAP11) - Blue keycard', 'Circle of Death (MAP11) - Red keycard', 'Downtown (MAP13) - Blue keycard', 'Downtown (MAP13) - Red keycard', 'Downtown (MAP13) - Yellow keycard', 'Industrial Zone (MAP15) - Blue keycard', 'Industrial Zone (MAP15) - Red keycard', 'Industrial Zone (MAP15) - Yellow keycard', 'Monster Condo (MAP27) - Blue skull key', 'Monster Condo (MAP27) - Red skull key', 'Monster Condo (MAP27) - Yellow skull key', 'Nirvana (MAP21) - Blue skull key', 'Nirvana (MAP21) - Red skull key', 'Nirvana (MAP21) - Yellow skull key', 'Refueling Base (MAP10) - Blue keycard', 'Refueling Base (MAP10) - Yellow keycard', 'Suburbs (MAP16) - Blue skull key', 'Suburbs (MAP16) - Red skull key', 'Tenements (MAP17) - Blue keycard', 'Tenements (MAP17) - Red keycard', 'Tenements (MAP17) - Yellow skull key', 'The Abandoned Mines (MAP26) - Blue keycard', 'The Abandoned Mines (MAP26) - Red keycard', 'The Abandoned Mines (MAP26) - Yellow keycard', 'The Catacombs (MAP22) - Blue skull key', 'The Catacombs (MAP22) - Red skull key', 'The Chasm (MAP24) - Blue keycard', 'The Chasm (MAP24) - Red keycard', 'The Citadel (MAP19) - Blue skull key', 'The Citadel (MAP19) - Red skull key', 'The Citadel (MAP19) - Yellow skull key', 'The Courtyard (MAP18) - Blue skull key', 'The Courtyard (MAP18) - Yellow skull key', 'The Crusher (MAP06) - Blue keycard', 'The Crusher (MAP06) - Red keycard', 'The Crusher (MAP06) - Yellow keycard', 'The Factory (MAP12) - Blue keycard', 'The Factory (MAP12) - Yellow keycard', 'The Focus (MAP04) - Blue keycard', 'The Focus (MAP04) - Red keycard', 'The Focus (MAP04) - Yellow keycard', 'The Gantlet (MAP03) - Blue keycard', 'The Gantlet (MAP03) - Red keycard', 'The Inmost Dens (MAP14) - Blue skull key', 'The Inmost Dens (MAP14) - Red skull key', 'The Pit (MAP09) - Blue keycard', 'The Pit (MAP09) - Yellow keycard', 'The Spirit World (MAP28) - Red skull key', 'The Spirit World (MAP28) - Yellow skull key', 'The Waste Tunnels (MAP05) - Blue keycard', 'The Waste Tunnels (MAP05) - Red keycard', 'The Waste Tunnels (MAP05) - Yellow keycard', 'Tricks and Traps (MAP08) - Red skull key', 'Tricks and Traps (MAP08) - Yellow skull key', 'Underhalls (MAP02) - Blue keycard', 'Underhalls (MAP02) - Red keycard', }, + 'Levels': {'Barrels o Fun (MAP23)', 'Bloodfalls (MAP25)', 'Circle of Death (MAP11)', 'Dead Simple (MAP07)', 'Downtown (MAP13)', 'Entryway (MAP01)', 'Gotcha! (MAP20)', 'Grosse2 (MAP32)', 'Icon of Sin (MAP30)', 'Industrial Zone (MAP15)', 'Monster Condo (MAP27)', 'Nirvana (MAP21)', 'Refueling Base (MAP10)', 'Suburbs (MAP16)', 'Tenements (MAP17)', 'The Abandoned Mines (MAP26)', 'The Catacombs (MAP22)', 'The Chasm (MAP24)', 'The Citadel (MAP19)', 'The Courtyard (MAP18)', 'The Crusher (MAP06)', 'The Factory (MAP12)', 'The Focus (MAP04)', 'The Gantlet (MAP03)', 'The Inmost Dens (MAP14)', 'The Living End (MAP29)', 'The Pit (MAP09)', 'The Spirit World (MAP28)', 'The Waste Tunnels (MAP05)', 'Tricks and Traps (MAP08)', 'Underhalls (MAP02)', 'Wolfenstein2 (MAP31)', }, + 'Powerups': {'Armor', 'Berserk', 'Invulnerability', 'Mega Armor', 'Megasphere', 'Partial invisibility', 'Supercharge', }, + 'Weapons': {'BFG9000', 'Chaingun', 'Chainsaw', 'Plasma gun', 'Rocket launcher', 'Shotgun', 'Super Shotgun', }, +} diff --git a/worlds/doom_ii/Locations.py b/worlds/doom_ii/Locations.py new file mode 100644 index 0000000000..3ce87b8a66 --- /dev/null +++ b/worlds/doom_ii/Locations.py @@ -0,0 +1,3442 @@ +# This file is auto generated. More info: https://github.com/Daivuk/apdoom + +from typing import Dict, TypedDict, List, Set + + +class LocationDict(TypedDict, total=False): + name: str + episode: int + map: int + index: int # Thing index as it is stored in the wad file. + doom_type: int # In case index end up unreliable, we can use doom type. Maps have often only one of each important things. + region: str + + +location_table: Dict[int, LocationDict] = { + 361000: {'name': 'Entryway (MAP01) - Armor', + 'episode': 1, + 'map': 1, + 'index': 17, + 'doom_type': 2018, + 'region': "Entryway (MAP01) Main"}, + 361001: {'name': 'Entryway (MAP01) - Shotgun', + 'episode': 1, + 'map': 1, + 'index': 37, + 'doom_type': 2001, + 'region': "Entryway (MAP01) Main"}, + 361002: {'name': 'Entryway (MAP01) - Rocket launcher', + 'episode': 1, + 'map': 1, + 'index': 52, + 'doom_type': 2003, + 'region': "Entryway (MAP01) Main"}, + 361003: {'name': 'Entryway (MAP01) - Chainsaw', + 'episode': 1, + 'map': 1, + 'index': 68, + 'doom_type': 2005, + 'region': "Entryway (MAP01) Main"}, + 361004: {'name': 'Entryway (MAP01) - Exit', + 'episode': 1, + 'map': 1, + 'index': -1, + 'doom_type': -1, + 'region': "Entryway (MAP01) Main"}, + 361005: {'name': 'Underhalls (MAP02) - Red keycard', + 'episode': 1, + 'map': 2, + 'index': 31, + 'doom_type': 13, + 'region': "Underhalls (MAP02) Main"}, + 361006: {'name': 'Underhalls (MAP02) - Blue keycard', + 'episode': 1, + 'map': 2, + 'index': 44, + 'doom_type': 5, + 'region': "Underhalls (MAP02) Red"}, + 361007: {'name': 'Underhalls (MAP02) - Mega Armor', + 'episode': 1, + 'map': 2, + 'index': 116, + 'doom_type': 2019, + 'region': "Underhalls (MAP02) Main"}, + 361008: {'name': 'Underhalls (MAP02) - Super Shotgun', + 'episode': 1, + 'map': 2, + 'index': 127, + 'doom_type': 82, + 'region': "Underhalls (MAP02) Main"}, + 361009: {'name': 'Underhalls (MAP02) - Exit', + 'episode': 1, + 'map': 2, + 'index': -1, + 'doom_type': -1, + 'region': "Underhalls (MAP02) Blue"}, + 361010: {'name': 'The Gantlet (MAP03) - Mega Armor', + 'episode': 1, + 'map': 3, + 'index': 5, + 'doom_type': 2019, + 'region': "The Gantlet (MAP03) Main"}, + 361011: {'name': 'The Gantlet (MAP03) - Shotgun', + 'episode': 1, + 'map': 3, + 'index': 6, + 'doom_type': 2001, + 'region': "The Gantlet (MAP03) Main"}, + 361012: {'name': 'The Gantlet (MAP03) - Blue keycard', + 'episode': 1, + 'map': 3, + 'index': 85, + 'doom_type': 5, + 'region': "The Gantlet (MAP03) Main"}, + 361013: {'name': 'The Gantlet (MAP03) - Rocket launcher', + 'episode': 1, + 'map': 3, + 'index': 86, + 'doom_type': 2003, + 'region': "The Gantlet (MAP03) Main"}, + 361014: {'name': 'The Gantlet (MAP03) - Partial invisibility', + 'episode': 1, + 'map': 3, + 'index': 96, + 'doom_type': 2024, + 'region': "The Gantlet (MAP03) Main"}, + 361015: {'name': 'The Gantlet (MAP03) - Supercharge', + 'episode': 1, + 'map': 3, + 'index': 97, + 'doom_type': 2013, + 'region': "The Gantlet (MAP03) Main"}, + 361016: {'name': 'The Gantlet (MAP03) - Mega Armor 2', + 'episode': 1, + 'map': 3, + 'index': 98, + 'doom_type': 2019, + 'region': "The Gantlet (MAP03) Main"}, + 361017: {'name': 'The Gantlet (MAP03) - Red keycard', + 'episode': 1, + 'map': 3, + 'index': 104, + 'doom_type': 13, + 'region': "The Gantlet (MAP03) Blue"}, + 361018: {'name': 'The Gantlet (MAP03) - Chaingun', + 'episode': 1, + 'map': 3, + 'index': 122, + 'doom_type': 2002, + 'region': "The Gantlet (MAP03) Main"}, + 361019: {'name': 'The Gantlet (MAP03) - Backpack', + 'episode': 1, + 'map': 3, + 'index': 146, + 'doom_type': 8, + 'region': "The Gantlet (MAP03) Blue"}, + 361020: {'name': 'The Gantlet (MAP03) - Exit', + 'episode': 1, + 'map': 3, + 'index': -1, + 'doom_type': -1, + 'region': "The Gantlet (MAP03) Red"}, + 361021: {'name': 'The Focus (MAP04) - Super Shotgun', + 'episode': 1, + 'map': 4, + 'index': 4, + 'doom_type': 82, + 'region': "The Focus (MAP04) Main"}, + 361022: {'name': 'The Focus (MAP04) - Blue keycard', + 'episode': 1, + 'map': 4, + 'index': 21, + 'doom_type': 5, + 'region': "The Focus (MAP04) Main"}, + 361023: {'name': 'The Focus (MAP04) - Red keycard', + 'episode': 1, + 'map': 4, + 'index': 32, + 'doom_type': 13, + 'region': "The Focus (MAP04) Blue"}, + 361024: {'name': 'The Focus (MAP04) - Yellow keycard', + 'episode': 1, + 'map': 4, + 'index': 59, + 'doom_type': 6, + 'region': "The Focus (MAP04) Red"}, + 361025: {'name': 'The Focus (MAP04) - Exit', + 'episode': 1, + 'map': 4, + 'index': -1, + 'doom_type': -1, + 'region': "The Focus (MAP04) Yellow"}, + 361026: {'name': 'The Waste Tunnels (MAP05) - Rocket launcher', + 'episode': 1, + 'map': 5, + 'index': 45, + 'doom_type': 2003, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361027: {'name': 'The Waste Tunnels (MAP05) - Super Shotgun', + 'episode': 1, + 'map': 5, + 'index': 46, + 'doom_type': 82, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361028: {'name': 'The Waste Tunnels (MAP05) - Blue keycard', + 'episode': 1, + 'map': 5, + 'index': 50, + 'doom_type': 5, + 'region': "The Waste Tunnels (MAP05) Red"}, + 361029: {'name': 'The Waste Tunnels (MAP05) - Plasma gun', + 'episode': 1, + 'map': 5, + 'index': 53, + 'doom_type': 2004, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361030: {'name': 'The Waste Tunnels (MAP05) - Red keycard', + 'episode': 1, + 'map': 5, + 'index': 55, + 'doom_type': 13, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361031: {'name': 'The Waste Tunnels (MAP05) - Supercharge', + 'episode': 1, + 'map': 5, + 'index': 56, + 'doom_type': 2013, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361032: {'name': 'The Waste Tunnels (MAP05) - Mega Armor', + 'episode': 1, + 'map': 5, + 'index': 57, + 'doom_type': 2019, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361033: {'name': 'The Waste Tunnels (MAP05) - Yellow keycard', + 'episode': 1, + 'map': 5, + 'index': 78, + 'doom_type': 6, + 'region': "The Waste Tunnels (MAP05) Blue"}, + 361034: {'name': 'The Waste Tunnels (MAP05) - Armor', + 'episode': 1, + 'map': 5, + 'index': 151, + 'doom_type': 2018, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361035: {'name': 'The Waste Tunnels (MAP05) - Supercharge 2', + 'episode': 1, + 'map': 5, + 'index': 170, + 'doom_type': 2013, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361036: {'name': 'The Waste Tunnels (MAP05) - Shotgun', + 'episode': 1, + 'map': 5, + 'index': 202, + 'doom_type': 2001, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361037: {'name': 'The Waste Tunnels (MAP05) - Berserk', + 'episode': 1, + 'map': 5, + 'index': 215, + 'doom_type': 2023, + 'region': "The Waste Tunnels (MAP05) Main"}, + 361038: {'name': 'The Waste Tunnels (MAP05) - Exit', + 'episode': 1, + 'map': 5, + 'index': -1, + 'doom_type': -1, + 'region': "The Waste Tunnels (MAP05) Yellow"}, + 361039: {'name': 'The Crusher (MAP06) - Red keycard', + 'episode': 1, + 'map': 6, + 'index': 0, + 'doom_type': 13, + 'region': "The Crusher (MAP06) Blue"}, + 361040: {'name': 'The Crusher (MAP06) - Yellow keycard', + 'episode': 1, + 'map': 6, + 'index': 1, + 'doom_type': 6, + 'region': "The Crusher (MAP06) Red"}, + 361041: {'name': 'The Crusher (MAP06) - Blue keycard', + 'episode': 1, + 'map': 6, + 'index': 36, + 'doom_type': 5, + 'region': "The Crusher (MAP06) Main"}, + 361042: {'name': 'The Crusher (MAP06) - Supercharge', + 'episode': 1, + 'map': 6, + 'index': 55, + 'doom_type': 2013, + 'region': "The Crusher (MAP06) Main"}, + 361043: {'name': 'The Crusher (MAP06) - Plasma gun', + 'episode': 1, + 'map': 6, + 'index': 59, + 'doom_type': 2004, + 'region': "The Crusher (MAP06) Main"}, + 361044: {'name': 'The Crusher (MAP06) - Blue keycard 2', + 'episode': 1, + 'map': 6, + 'index': 74, + 'doom_type': 5, + 'region': "The Crusher (MAP06) Main"}, + 361045: {'name': 'The Crusher (MAP06) - Blue keycard 3', + 'episode': 1, + 'map': 6, + 'index': 75, + 'doom_type': 5, + 'region': "The Crusher (MAP06) Main"}, + 361046: {'name': 'The Crusher (MAP06) - Megasphere', + 'episode': 1, + 'map': 6, + 'index': 94, + 'doom_type': 83, + 'region': "The Crusher (MAP06) Main"}, + 361047: {'name': 'The Crusher (MAP06) - Armor', + 'episode': 1, + 'map': 6, + 'index': 130, + 'doom_type': 2018, + 'region': "The Crusher (MAP06) Main"}, + 361048: {'name': 'The Crusher (MAP06) - Super Shotgun', + 'episode': 1, + 'map': 6, + 'index': 134, + 'doom_type': 82, + 'region': "The Crusher (MAP06) Blue"}, + 361049: {'name': 'The Crusher (MAP06) - Mega Armor', + 'episode': 1, + 'map': 6, + 'index': 222, + 'doom_type': 2019, + 'region': "The Crusher (MAP06) Blue"}, + 361050: {'name': 'The Crusher (MAP06) - Rocket launcher', + 'episode': 1, + 'map': 6, + 'index': 223, + 'doom_type': 2003, + 'region': "The Crusher (MAP06) Blue"}, + 361051: {'name': 'The Crusher (MAP06) - Backpack', + 'episode': 1, + 'map': 6, + 'index': 225, + 'doom_type': 8, + 'region': "The Crusher (MAP06) Blue"}, + 361052: {'name': 'The Crusher (MAP06) - Megasphere 2', + 'episode': 1, + 'map': 6, + 'index': 246, + 'doom_type': 83, + 'region': "The Crusher (MAP06) Blue"}, + 361053: {'name': 'The Crusher (MAP06) - Exit', + 'episode': 1, + 'map': 6, + 'index': -1, + 'doom_type': -1, + 'region': "The Crusher (MAP06) Yellow"}, + 361054: {'name': 'Dead Simple (MAP07) - Megasphere', + 'episode': 1, + 'map': 7, + 'index': 4, + 'doom_type': 83, + 'region': "Dead Simple (MAP07) Main"}, + 361055: {'name': 'Dead Simple (MAP07) - Rocket launcher', + 'episode': 1, + 'map': 7, + 'index': 5, + 'doom_type': 2003, + 'region': "Dead Simple (MAP07) Main"}, + 361056: {'name': 'Dead Simple (MAP07) - Partial invisibility', + 'episode': 1, + 'map': 7, + 'index': 7, + 'doom_type': 2024, + 'region': "Dead Simple (MAP07) Main"}, + 361057: {'name': 'Dead Simple (MAP07) - Super Shotgun', + 'episode': 1, + 'map': 7, + 'index': 8, + 'doom_type': 82, + 'region': "Dead Simple (MAP07) Main"}, + 361058: {'name': 'Dead Simple (MAP07) - Chaingun', + 'episode': 1, + 'map': 7, + 'index': 9, + 'doom_type': 2002, + 'region': "Dead Simple (MAP07) Main"}, + 361059: {'name': 'Dead Simple (MAP07) - Plasma gun', + 'episode': 1, + 'map': 7, + 'index': 10, + 'doom_type': 2004, + 'region': "Dead Simple (MAP07) Main"}, + 361060: {'name': 'Dead Simple (MAP07) - Backpack', + 'episode': 1, + 'map': 7, + 'index': 43, + 'doom_type': 8, + 'region': "Dead Simple (MAP07) Main"}, + 361061: {'name': 'Dead Simple (MAP07) - Berserk', + 'episode': 1, + 'map': 7, + 'index': 44, + 'doom_type': 2023, + 'region': "Dead Simple (MAP07) Main"}, + 361062: {'name': 'Dead Simple (MAP07) - Partial invisibility 2', + 'episode': 1, + 'map': 7, + 'index': 60, + 'doom_type': 2024, + 'region': "Dead Simple (MAP07) Main"}, + 361063: {'name': 'Dead Simple (MAP07) - Partial invisibility 3', + 'episode': 1, + 'map': 7, + 'index': 73, + 'doom_type': 2024, + 'region': "Dead Simple (MAP07) Main"}, + 361064: {'name': 'Dead Simple (MAP07) - Partial invisibility 4', + 'episode': 1, + 'map': 7, + 'index': 74, + 'doom_type': 2024, + 'region': "Dead Simple (MAP07) Main"}, + 361065: {'name': 'Dead Simple (MAP07) - Exit', + 'episode': 1, + 'map': 7, + 'index': -1, + 'doom_type': -1, + 'region': "Dead Simple (MAP07) Main"}, + 361066: {'name': 'Tricks and Traps (MAP08) - Plasma gun', + 'episode': 1, + 'map': 8, + 'index': 14, + 'doom_type': 2004, + 'region': "Tricks and Traps (MAP08) Main"}, + 361067: {'name': 'Tricks and Traps (MAP08) - Rocket launcher', + 'episode': 1, + 'map': 8, + 'index': 17, + 'doom_type': 2003, + 'region': "Tricks and Traps (MAP08) Main"}, + 361068: {'name': 'Tricks and Traps (MAP08) - Armor', + 'episode': 1, + 'map': 8, + 'index': 36, + 'doom_type': 2018, + 'region': "Tricks and Traps (MAP08) Main"}, + 361069: {'name': 'Tricks and Traps (MAP08) - Chaingun', + 'episode': 1, + 'map': 8, + 'index': 48, + 'doom_type': 2002, + 'region': "Tricks and Traps (MAP08) Main"}, + 361070: {'name': 'Tricks and Traps (MAP08) - Shotgun', + 'episode': 1, + 'map': 8, + 'index': 87, + 'doom_type': 2001, + 'region': "Tricks and Traps (MAP08) Main"}, + 361071: {'name': 'Tricks and Traps (MAP08) - Supercharge', + 'episode': 1, + 'map': 8, + 'index': 119, + 'doom_type': 2013, + 'region': "Tricks and Traps (MAP08) Main"}, + 361072: {'name': 'Tricks and Traps (MAP08) - Invulnerability', + 'episode': 1, + 'map': 8, + 'index': 120, + 'doom_type': 2022, + 'region': "Tricks and Traps (MAP08) Main"}, + 361073: {'name': 'Tricks and Traps (MAP08) - Invulnerability 2', + 'episode': 1, + 'map': 8, + 'index': 122, + 'doom_type': 2022, + 'region': "Tricks and Traps (MAP08) Main"}, + 361074: {'name': 'Tricks and Traps (MAP08) - Yellow skull key', + 'episode': 1, + 'map': 8, + 'index': 123, + 'doom_type': 39, + 'region': "Tricks and Traps (MAP08) Main"}, + 361075: {'name': 'Tricks and Traps (MAP08) - Backpack', + 'episode': 1, + 'map': 8, + 'index': 133, + 'doom_type': 8, + 'region': "Tricks and Traps (MAP08) Main"}, + 361076: {'name': 'Tricks and Traps (MAP08) - Backpack 2', + 'episode': 1, + 'map': 8, + 'index': 134, + 'doom_type': 8, + 'region': "Tricks and Traps (MAP08) Main"}, + 361077: {'name': 'Tricks and Traps (MAP08) - Invulnerability 3', + 'episode': 1, + 'map': 8, + 'index': 135, + 'doom_type': 2022, + 'region': "Tricks and Traps (MAP08) Main"}, + 361078: {'name': 'Tricks and Traps (MAP08) - Invulnerability 4', + 'episode': 1, + 'map': 8, + 'index': 136, + 'doom_type': 2022, + 'region': "Tricks and Traps (MAP08) Main"}, + 361079: {'name': 'Tricks and Traps (MAP08) - BFG9000', + 'episode': 1, + 'map': 8, + 'index': 161, + 'doom_type': 2006, + 'region': "Tricks and Traps (MAP08) Main"}, + 361080: {'name': 'Tricks and Traps (MAP08) - Supercharge 2', + 'episode': 1, + 'map': 8, + 'index': 162, + 'doom_type': 2013, + 'region': "Tricks and Traps (MAP08) Main"}, + 361081: {'name': 'Tricks and Traps (MAP08) - Backpack 3', + 'episode': 1, + 'map': 8, + 'index': 163, + 'doom_type': 8, + 'region': "Tricks and Traps (MAP08) Main"}, + 361082: {'name': 'Tricks and Traps (MAP08) - Backpack 4', + 'episode': 1, + 'map': 8, + 'index': 164, + 'doom_type': 8, + 'region': "Tricks and Traps (MAP08) Main"}, + 361083: {'name': 'Tricks and Traps (MAP08) - Chainsaw', + 'episode': 1, + 'map': 8, + 'index': 168, + 'doom_type': 2005, + 'region': "Tricks and Traps (MAP08) Main"}, + 361084: {'name': 'Tricks and Traps (MAP08) - Red skull key', + 'episode': 1, + 'map': 8, + 'index': 176, + 'doom_type': 38, + 'region': "Tricks and Traps (MAP08) Yellow"}, + 361085: {'name': 'Tricks and Traps (MAP08) - Invulnerability 5', + 'episode': 1, + 'map': 8, + 'index': 202, + 'doom_type': 2022, + 'region': "Tricks and Traps (MAP08) Yellow"}, + 361086: {'name': 'Tricks and Traps (MAP08) - Armor 2', + 'episode': 1, + 'map': 8, + 'index': 220, + 'doom_type': 2018, + 'region': "Tricks and Traps (MAP08) Main"}, + 361087: {'name': 'Tricks and Traps (MAP08) - Backpack 5', + 'episode': 1, + 'map': 8, + 'index': 226, + 'doom_type': 8, + 'region': "Tricks and Traps (MAP08) Main"}, + 361088: {'name': 'Tricks and Traps (MAP08) - Partial invisibility', + 'episode': 1, + 'map': 8, + 'index': 235, + 'doom_type': 2024, + 'region': "Tricks and Traps (MAP08) Main"}, + 361089: {'name': 'Tricks and Traps (MAP08) - Exit', + 'episode': 1, + 'map': 8, + 'index': -1, + 'doom_type': -1, + 'region': "Tricks and Traps (MAP08) Red"}, + 361090: {'name': 'The Pit (MAP09) - Berserk', + 'episode': 1, + 'map': 9, + 'index': 5, + 'doom_type': 2023, + 'region': "The Pit (MAP09) Main"}, + 361091: {'name': 'The Pit (MAP09) - Shotgun', + 'episode': 1, + 'map': 9, + 'index': 21, + 'doom_type': 2001, + 'region': "The Pit (MAP09) Main"}, + 361092: {'name': 'The Pit (MAP09) - Mega Armor', + 'episode': 1, + 'map': 9, + 'index': 26, + 'doom_type': 2019, + 'region': "The Pit (MAP09) Main"}, + 361093: {'name': 'The Pit (MAP09) - Supercharge', + 'episode': 1, + 'map': 9, + 'index': 78, + 'doom_type': 2013, + 'region': "The Pit (MAP09) Main"}, + 361094: {'name': 'The Pit (MAP09) - Berserk 2', + 'episode': 1, + 'map': 9, + 'index': 90, + 'doom_type': 2023, + 'region': "The Pit (MAP09) Main"}, + 361095: {'name': 'The Pit (MAP09) - Rocket launcher', + 'episode': 1, + 'map': 9, + 'index': 92, + 'doom_type': 2003, + 'region': "The Pit (MAP09) Main"}, + 361096: {'name': 'The Pit (MAP09) - BFG9000', + 'episode': 1, + 'map': 9, + 'index': 184, + 'doom_type': 2006, + 'region': "The Pit (MAP09) Main"}, + 361097: {'name': 'The Pit (MAP09) - Blue keycard', + 'episode': 1, + 'map': 9, + 'index': 185, + 'doom_type': 5, + 'region': "The Pit (MAP09) Main"}, + 361098: {'name': 'The Pit (MAP09) - Yellow keycard', + 'episode': 1, + 'map': 9, + 'index': 226, + 'doom_type': 6, + 'region': "The Pit (MAP09) Blue"}, + 361099: {'name': 'The Pit (MAP09) - Backpack', + 'episode': 1, + 'map': 9, + 'index': 244, + 'doom_type': 8, + 'region': "The Pit (MAP09) Blue"}, + 361100: {'name': 'The Pit (MAP09) - Computer area map', + 'episode': 1, + 'map': 9, + 'index': 245, + 'doom_type': 2026, + 'region': "The Pit (MAP09) Blue"}, + 361101: {'name': 'The Pit (MAP09) - Supercharge 2', + 'episode': 1, + 'map': 9, + 'index': 250, + 'doom_type': 2013, + 'region': "The Pit (MAP09) Blue"}, + 361102: {'name': 'The Pit (MAP09) - Mega Armor 2', + 'episode': 1, + 'map': 9, + 'index': 251, + 'doom_type': 2019, + 'region': "The Pit (MAP09) Blue"}, + 361103: {'name': 'The Pit (MAP09) - Berserk 3', + 'episode': 1, + 'map': 9, + 'index': 309, + 'doom_type': 2023, + 'region': "The Pit (MAP09) Blue"}, + 361104: {'name': 'The Pit (MAP09) - Armor', + 'episode': 1, + 'map': 9, + 'index': 348, + 'doom_type': 2018, + 'region': "The Pit (MAP09) Main"}, + 361105: {'name': 'The Pit (MAP09) - Exit', + 'episode': 1, + 'map': 9, + 'index': -1, + 'doom_type': -1, + 'region': "The Pit (MAP09) Yellow"}, + 361106: {'name': 'Refueling Base (MAP10) - BFG9000', + 'episode': 1, + 'map': 10, + 'index': 17, + 'doom_type': 2006, + 'region': "Refueling Base (MAP10) Main"}, + 361107: {'name': 'Refueling Base (MAP10) - Supercharge', + 'episode': 1, + 'map': 10, + 'index': 28, + 'doom_type': 2013, + 'region': "Refueling Base (MAP10) Main"}, + 361108: {'name': 'Refueling Base (MAP10) - Plasma gun', + 'episode': 1, + 'map': 10, + 'index': 29, + 'doom_type': 2004, + 'region': "Refueling Base (MAP10) Main"}, + 361109: {'name': 'Refueling Base (MAP10) - Blue keycard', + 'episode': 1, + 'map': 10, + 'index': 50, + 'doom_type': 5, + 'region': "Refueling Base (MAP10) Main"}, + 361110: {'name': 'Refueling Base (MAP10) - Shotgun', + 'episode': 1, + 'map': 10, + 'index': 99, + 'doom_type': 2001, + 'region': "Refueling Base (MAP10) Main"}, + 361111: {'name': 'Refueling Base (MAP10) - Chaingun', + 'episode': 1, + 'map': 10, + 'index': 158, + 'doom_type': 2002, + 'region': "Refueling Base (MAP10) Main"}, + 361112: {'name': 'Refueling Base (MAP10) - Armor', + 'episode': 1, + 'map': 10, + 'index': 172, + 'doom_type': 2018, + 'region': "Refueling Base (MAP10) Main"}, + 361113: {'name': 'Refueling Base (MAP10) - Rocket launcher', + 'episode': 1, + 'map': 10, + 'index': 291, + 'doom_type': 2003, + 'region': "Refueling Base (MAP10) Main"}, + 361114: {'name': 'Refueling Base (MAP10) - Supercharge 2', + 'episode': 1, + 'map': 10, + 'index': 359, + 'doom_type': 2013, + 'region': "Refueling Base (MAP10) Main"}, + 361115: {'name': 'Refueling Base (MAP10) - Backpack', + 'episode': 1, + 'map': 10, + 'index': 368, + 'doom_type': 8, + 'region': "Refueling Base (MAP10) Main"}, + 361116: {'name': 'Refueling Base (MAP10) - Berserk', + 'episode': 1, + 'map': 10, + 'index': 392, + 'doom_type': 2023, + 'region': "Refueling Base (MAP10) Main"}, + 361117: {'name': 'Refueling Base (MAP10) - Mega Armor', + 'episode': 1, + 'map': 10, + 'index': 395, + 'doom_type': 2019, + 'region': "Refueling Base (MAP10) Main"}, + 361118: {'name': 'Refueling Base (MAP10) - Invulnerability', + 'episode': 1, + 'map': 10, + 'index': 396, + 'doom_type': 2022, + 'region': "Refueling Base (MAP10) Main"}, + 361119: {'name': 'Refueling Base (MAP10) - Invulnerability 2', + 'episode': 1, + 'map': 10, + 'index': 398, + 'doom_type': 2022, + 'region': "Refueling Base (MAP10) Main"}, + 361120: {'name': 'Refueling Base (MAP10) - Armor 2', + 'episode': 1, + 'map': 10, + 'index': 400, + 'doom_type': 2018, + 'region': "Refueling Base (MAP10) Main"}, + 361121: {'name': 'Refueling Base (MAP10) - Berserk 2', + 'episode': 1, + 'map': 10, + 'index': 441, + 'doom_type': 2023, + 'region': "Refueling Base (MAP10) Main"}, + 361122: {'name': 'Refueling Base (MAP10) - Partial invisibility', + 'episode': 1, + 'map': 10, + 'index': 470, + 'doom_type': 2024, + 'region': "Refueling Base (MAP10) Main"}, + 361123: {'name': 'Refueling Base (MAP10) - Chainsaw', + 'episode': 1, + 'map': 10, + 'index': 472, + 'doom_type': 2005, + 'region': "Refueling Base (MAP10) Main"}, + 361124: {'name': 'Refueling Base (MAP10) - Yellow keycard', + 'episode': 1, + 'map': 10, + 'index': 473, + 'doom_type': 6, + 'region': "Refueling Base (MAP10) Main"}, + 361125: {'name': 'Refueling Base (MAP10) - Megasphere', + 'episode': 1, + 'map': 10, + 'index': 507, + 'doom_type': 83, + 'region': "Refueling Base (MAP10) Main"}, + 361126: {'name': 'Refueling Base (MAP10) - Exit', + 'episode': 1, + 'map': 10, + 'index': -1, + 'doom_type': -1, + 'region': "Refueling Base (MAP10) Yellow Blue"}, + 361127: {'name': 'Circle of Death (MAP11) - Red keycard', + 'episode': 1, + 'map': 11, + 'index': 1, + 'doom_type': 13, + 'region': "Circle of Death (MAP11) Main"}, + 361128: {'name': 'Circle of Death (MAP11) - Chaingun', + 'episode': 1, + 'map': 11, + 'index': 14, + 'doom_type': 2002, + 'region': "Circle of Death (MAP11) Main"}, + 361129: {'name': 'Circle of Death (MAP11) - Supercharge', + 'episode': 1, + 'map': 11, + 'index': 23, + 'doom_type': 2013, + 'region': "Circle of Death (MAP11) Main"}, + 361130: {'name': 'Circle of Death (MAP11) - Plasma gun', + 'episode': 1, + 'map': 11, + 'index': 30, + 'doom_type': 2004, + 'region': "Circle of Death (MAP11) Main"}, + 361131: {'name': 'Circle of Death (MAP11) - Blue keycard', + 'episode': 1, + 'map': 11, + 'index': 40, + 'doom_type': 5, + 'region': "Circle of Death (MAP11) Main"}, + 361132: {'name': 'Circle of Death (MAP11) - Armor', + 'episode': 1, + 'map': 11, + 'index': 42, + 'doom_type': 2018, + 'region': "Circle of Death (MAP11) Main"}, + 361133: {'name': 'Circle of Death (MAP11) - Shotgun', + 'episode': 1, + 'map': 11, + 'index': 50, + 'doom_type': 2001, + 'region': "Circle of Death (MAP11) Main"}, + 361134: {'name': 'Circle of Death (MAP11) - Mega Armor', + 'episode': 1, + 'map': 11, + 'index': 58, + 'doom_type': 2019, + 'region': "Circle of Death (MAP11) Blue"}, + 361135: {'name': 'Circle of Death (MAP11) - Partial invisibility', + 'episode': 1, + 'map': 11, + 'index': 70, + 'doom_type': 2024, + 'region': "Circle of Death (MAP11) Main"}, + 361136: {'name': 'Circle of Death (MAP11) - Invulnerability', + 'episode': 1, + 'map': 11, + 'index': 83, + 'doom_type': 2022, + 'region': "Circle of Death (MAP11) Red"}, + 361137: {'name': 'Circle of Death (MAP11) - Rocket launcher', + 'episode': 1, + 'map': 11, + 'index': 86, + 'doom_type': 2003, + 'region': "Circle of Death (MAP11) Red"}, + 361138: {'name': 'Circle of Death (MAP11) - Backpack', + 'episode': 1, + 'map': 11, + 'index': 88, + 'doom_type': 8, + 'region': "Circle of Death (MAP11) Red"}, + 361139: {'name': 'Circle of Death (MAP11) - Supercharge 2', + 'episode': 1, + 'map': 11, + 'index': 108, + 'doom_type': 2013, + 'region': "Circle of Death (MAP11) Red"}, + 361140: {'name': 'Circle of Death (MAP11) - BFG9000', + 'episode': 1, + 'map': 11, + 'index': 110, + 'doom_type': 2006, + 'region': "Circle of Death (MAP11) Red"}, + 361141: {'name': 'Circle of Death (MAP11) - Exit', + 'episode': 1, + 'map': 11, + 'index': -1, + 'doom_type': -1, + 'region': "Circle of Death (MAP11) Red"}, + 361142: {'name': 'The Factory (MAP12) - Shotgun', + 'episode': 2, + 'map': 1, + 'index': 14, + 'doom_type': 2001, + 'region': "The Factory (MAP12) Main"}, + 361143: {'name': 'The Factory (MAP12) - Berserk', + 'episode': 2, + 'map': 1, + 'index': 35, + 'doom_type': 2023, + 'region': "The Factory (MAP12) Main"}, + 361144: {'name': 'The Factory (MAP12) - Chaingun', + 'episode': 2, + 'map': 1, + 'index': 38, + 'doom_type': 2002, + 'region': "The Factory (MAP12) Main"}, + 361145: {'name': 'The Factory (MAP12) - Supercharge', + 'episode': 2, + 'map': 1, + 'index': 52, + 'doom_type': 2013, + 'region': "The Factory (MAP12) Main"}, + 361146: {'name': 'The Factory (MAP12) - Blue keycard', + 'episode': 2, + 'map': 1, + 'index': 54, + 'doom_type': 5, + 'region': "The Factory (MAP12) Main"}, + 361147: {'name': 'The Factory (MAP12) - Armor', + 'episode': 2, + 'map': 1, + 'index': 63, + 'doom_type': 2018, + 'region': "The Factory (MAP12) Blue"}, + 361148: {'name': 'The Factory (MAP12) - Backpack', + 'episode': 2, + 'map': 1, + 'index': 70, + 'doom_type': 8, + 'region': "The Factory (MAP12) Blue"}, + 361149: {'name': 'The Factory (MAP12) - Supercharge 2', + 'episode': 2, + 'map': 1, + 'index': 83, + 'doom_type': 2013, + 'region': "The Factory (MAP12) Main"}, + 361150: {'name': 'The Factory (MAP12) - Armor 2', + 'episode': 2, + 'map': 1, + 'index': 92, + 'doom_type': 2018, + 'region': "The Factory (MAP12) Main"}, + 361151: {'name': 'The Factory (MAP12) - Partial invisibility', + 'episode': 2, + 'map': 1, + 'index': 93, + 'doom_type': 2024, + 'region': "The Factory (MAP12) Main"}, + 361152: {'name': 'The Factory (MAP12) - Berserk 2', + 'episode': 2, + 'map': 1, + 'index': 107, + 'doom_type': 2023, + 'region': "The Factory (MAP12) Main"}, + 361153: {'name': 'The Factory (MAP12) - Yellow keycard', + 'episode': 2, + 'map': 1, + 'index': 123, + 'doom_type': 6, + 'region': "The Factory (MAP12) Main"}, + 361154: {'name': 'The Factory (MAP12) - BFG9000', + 'episode': 2, + 'map': 1, + 'index': 135, + 'doom_type': 2006, + 'region': "The Factory (MAP12) Blue"}, + 361155: {'name': 'The Factory (MAP12) - Berserk 3', + 'episode': 2, + 'map': 1, + 'index': 189, + 'doom_type': 2023, + 'region': "The Factory (MAP12) Main"}, + 361156: {'name': 'The Factory (MAP12) - Super Shotgun', + 'episode': 2, + 'map': 1, + 'index': 192, + 'doom_type': 82, + 'region': "The Factory (MAP12) Main"}, + 361157: {'name': 'The Factory (MAP12) - Exit', + 'episode': 2, + 'map': 1, + 'index': -1, + 'doom_type': -1, + 'region': "The Factory (MAP12) Yellow"}, + 361158: {'name': 'Downtown (MAP13) - Rocket launcher', + 'episode': 2, + 'map': 2, + 'index': 4, + 'doom_type': 2003, + 'region': "Downtown (MAP13) Main"}, + 361159: {'name': 'Downtown (MAP13) - Shotgun', + 'episode': 2, + 'map': 2, + 'index': 42, + 'doom_type': 2001, + 'region': "Downtown (MAP13) Main"}, + 361160: {'name': 'Downtown (MAP13) - Supercharge', + 'episode': 2, + 'map': 2, + 'index': 73, + 'doom_type': 2013, + 'region': "Downtown (MAP13) Main"}, + 361161: {'name': 'Downtown (MAP13) - Berserk', + 'episode': 2, + 'map': 2, + 'index': 131, + 'doom_type': 2023, + 'region': "Downtown (MAP13) Main"}, + 361162: {'name': 'Downtown (MAP13) - Mega Armor', + 'episode': 2, + 'map': 2, + 'index': 158, + 'doom_type': 2019, + 'region': "Downtown (MAP13) Main"}, + 361163: {'name': 'Downtown (MAP13) - Chaingun', + 'episode': 2, + 'map': 2, + 'index': 183, + 'doom_type': 2002, + 'region': "Downtown (MAP13) Main"}, + 361164: {'name': 'Downtown (MAP13) - Blue keycard', + 'episode': 2, + 'map': 2, + 'index': 195, + 'doom_type': 5, + 'region': "Downtown (MAP13) Main"}, + 361165: {'name': 'Downtown (MAP13) - Yellow keycard', + 'episode': 2, + 'map': 2, + 'index': 201, + 'doom_type': 6, + 'region': "Downtown (MAP13) Red"}, + 361166: {'name': 'Downtown (MAP13) - Berserk 2', + 'episode': 2, + 'map': 2, + 'index': 207, + 'doom_type': 2023, + 'region': "Downtown (MAP13) Red"}, + 361167: {'name': 'Downtown (MAP13) - Plasma gun', + 'episode': 2, + 'map': 2, + 'index': 231, + 'doom_type': 2004, + 'region': "Downtown (MAP13) Main"}, + 361168: {'name': 'Downtown (MAP13) - Partial invisibility', + 'episode': 2, + 'map': 2, + 'index': 249, + 'doom_type': 2024, + 'region': "Downtown (MAP13) Main"}, + 361169: {'name': 'Downtown (MAP13) - Backpack', + 'episode': 2, + 'map': 2, + 'index': 250, + 'doom_type': 8, + 'region': "Downtown (MAP13) Main"}, + 361170: {'name': 'Downtown (MAP13) - Chainsaw', + 'episode': 2, + 'map': 2, + 'index': 257, + 'doom_type': 2005, + 'region': "Downtown (MAP13) Blue"}, + 361171: {'name': 'Downtown (MAP13) - BFG9000', + 'episode': 2, + 'map': 2, + 'index': 258, + 'doom_type': 2006, + 'region': "Downtown (MAP13) Main"}, + 361172: {'name': 'Downtown (MAP13) - Invulnerability', + 'episode': 2, + 'map': 2, + 'index': 269, + 'doom_type': 2022, + 'region': "Downtown (MAP13) Blue"}, + 361173: {'name': 'Downtown (MAP13) - Invulnerability 2', + 'episode': 2, + 'map': 2, + 'index': 280, + 'doom_type': 2022, + 'region': "Downtown (MAP13) Main"}, + 361174: {'name': 'Downtown (MAP13) - Partial invisibility 2', + 'episode': 2, + 'map': 2, + 'index': 281, + 'doom_type': 2024, + 'region': "Downtown (MAP13) Main"}, + 361175: {'name': 'Downtown (MAP13) - Partial invisibility 3', + 'episode': 2, + 'map': 2, + 'index': 282, + 'doom_type': 2024, + 'region': "Downtown (MAP13) Main"}, + 361176: {'name': 'Downtown (MAP13) - Red keycard', + 'episode': 2, + 'map': 2, + 'index': 283, + 'doom_type': 13, + 'region': "Downtown (MAP13) Blue"}, + 361177: {'name': 'Downtown (MAP13) - Berserk 3', + 'episode': 2, + 'map': 2, + 'index': 296, + 'doom_type': 2023, + 'region': "Downtown (MAP13) Yellow"}, + 361178: {'name': 'Downtown (MAP13) - Computer area map', + 'episode': 2, + 'map': 2, + 'index': 298, + 'doom_type': 2026, + 'region': "Downtown (MAP13) Main"}, + 361179: {'name': 'Downtown (MAP13) - Exit', + 'episode': 2, + 'map': 2, + 'index': -1, + 'doom_type': -1, + 'region': "Downtown (MAP13) Yellow"}, + 361180: {'name': 'The Inmost Dens (MAP14) - Shotgun', + 'episode': 2, + 'map': 3, + 'index': 13, + 'doom_type': 2001, + 'region': "The Inmost Dens (MAP14) Main"}, + 361181: {'name': 'The Inmost Dens (MAP14) - Supercharge', + 'episode': 2, + 'map': 3, + 'index': 16, + 'doom_type': 2013, + 'region': "The Inmost Dens (MAP14) Main"}, + 361182: {'name': 'The Inmost Dens (MAP14) - Mega Armor', + 'episode': 2, + 'map': 3, + 'index': 22, + 'doom_type': 2019, + 'region': "The Inmost Dens (MAP14) Main"}, + 361183: {'name': 'The Inmost Dens (MAP14) - Berserk', + 'episode': 2, + 'map': 3, + 'index': 78, + 'doom_type': 2023, + 'region': "The Inmost Dens (MAP14) Main"}, + 361184: {'name': 'The Inmost Dens (MAP14) - Chaingun', + 'episode': 2, + 'map': 3, + 'index': 80, + 'doom_type': 2002, + 'region': "The Inmost Dens (MAP14) Main"}, + 361185: {'name': 'The Inmost Dens (MAP14) - Plasma gun', + 'episode': 2, + 'map': 3, + 'index': 81, + 'doom_type': 2004, + 'region': "The Inmost Dens (MAP14) Main"}, + 361186: {'name': 'The Inmost Dens (MAP14) - Red skull key', + 'episode': 2, + 'map': 3, + 'index': 119, + 'doom_type': 38, + 'region': "The Inmost Dens (MAP14) Main"}, + 361187: {'name': 'The Inmost Dens (MAP14) - Rocket launcher', + 'episode': 2, + 'map': 3, + 'index': 123, + 'doom_type': 2003, + 'region': "The Inmost Dens (MAP14) Main"}, + 361188: {'name': 'The Inmost Dens (MAP14) - Blue skull key', + 'episode': 2, + 'map': 3, + 'index': 130, + 'doom_type': 40, + 'region': "The Inmost Dens (MAP14) Red South"}, + 361189: {'name': 'The Inmost Dens (MAP14) - Partial invisibility', + 'episode': 2, + 'map': 3, + 'index': 138, + 'doom_type': 2024, + 'region': "The Inmost Dens (MAP14) Red South"}, + 361190: {'name': 'The Inmost Dens (MAP14) - Exit', + 'episode': 2, + 'map': 3, + 'index': -1, + 'doom_type': -1, + 'region': "The Inmost Dens (MAP14) Blue"}, + 361191: {'name': 'Industrial Zone (MAP15) - Berserk', + 'episode': 2, + 'map': 4, + 'index': 4, + 'doom_type': 2023, + 'region': "Industrial Zone (MAP15) Main"}, + 361192: {'name': 'Industrial Zone (MAP15) - Rocket launcher', + 'episode': 2, + 'map': 4, + 'index': 11, + 'doom_type': 2003, + 'region': "Industrial Zone (MAP15) Main"}, + 361193: {'name': 'Industrial Zone (MAP15) - Shotgun', + 'episode': 2, + 'map': 4, + 'index': 13, + 'doom_type': 2001, + 'region': "Industrial Zone (MAP15) Main"}, + 361194: {'name': 'Industrial Zone (MAP15) - Partial invisibility', + 'episode': 2, + 'map': 4, + 'index': 14, + 'doom_type': 2024, + 'region': "Industrial Zone (MAP15) Main"}, + 361195: {'name': 'Industrial Zone (MAP15) - Backpack', + 'episode': 2, + 'map': 4, + 'index': 24, + 'doom_type': 8, + 'region': "Industrial Zone (MAP15) Main"}, + 361196: {'name': 'Industrial Zone (MAP15) - BFG9000', + 'episode': 2, + 'map': 4, + 'index': 48, + 'doom_type': 2006, + 'region': "Industrial Zone (MAP15) Main"}, + 361197: {'name': 'Industrial Zone (MAP15) - Supercharge', + 'episode': 2, + 'map': 4, + 'index': 56, + 'doom_type': 2013, + 'region': "Industrial Zone (MAP15) Main"}, + 361198: {'name': 'Industrial Zone (MAP15) - Mega Armor', + 'episode': 2, + 'map': 4, + 'index': 57, + 'doom_type': 2019, + 'region': "Industrial Zone (MAP15) Main"}, + 361199: {'name': 'Industrial Zone (MAP15) - Armor', + 'episode': 2, + 'map': 4, + 'index': 59, + 'doom_type': 2018, + 'region': "Industrial Zone (MAP15) Main"}, + 361200: {'name': 'Industrial Zone (MAP15) - Yellow keycard', + 'episode': 2, + 'map': 4, + 'index': 71, + 'doom_type': 6, + 'region': "Industrial Zone (MAP15) Main"}, + 361201: {'name': 'Industrial Zone (MAP15) - Chaingun', + 'episode': 2, + 'map': 4, + 'index': 74, + 'doom_type': 2002, + 'region': "Industrial Zone (MAP15) Main"}, + 361202: {'name': 'Industrial Zone (MAP15) - Plasma gun', + 'episode': 2, + 'map': 4, + 'index': 86, + 'doom_type': 2004, + 'region': "Industrial Zone (MAP15) Yellow West"}, + 361203: {'name': 'Industrial Zone (MAP15) - Partial invisibility 2', + 'episode': 2, + 'map': 4, + 'index': 91, + 'doom_type': 2024, + 'region': "Industrial Zone (MAP15) Yellow West"}, + 361204: {'name': 'Industrial Zone (MAP15) - Computer area map', + 'episode': 2, + 'map': 4, + 'index': 93, + 'doom_type': 2026, + 'region': "Industrial Zone (MAP15) Yellow West"}, + 361205: {'name': 'Industrial Zone (MAP15) - Invulnerability', + 'episode': 2, + 'map': 4, + 'index': 94, + 'doom_type': 2022, + 'region': "Industrial Zone (MAP15) Main"}, + 361206: {'name': 'Industrial Zone (MAP15) - Red keycard', + 'episode': 2, + 'map': 4, + 'index': 100, + 'doom_type': 13, + 'region': "Industrial Zone (MAP15) Main"}, + 361207: {'name': 'Industrial Zone (MAP15) - Backpack 2', + 'episode': 2, + 'map': 4, + 'index': 103, + 'doom_type': 8, + 'region': "Industrial Zone (MAP15) Yellow West"}, + 361208: {'name': 'Industrial Zone (MAP15) - Chainsaw', + 'episode': 2, + 'map': 4, + 'index': 113, + 'doom_type': 2005, + 'region': "Industrial Zone (MAP15) Yellow East"}, + 361209: {'name': 'Industrial Zone (MAP15) - Megasphere', + 'episode': 2, + 'map': 4, + 'index': 125, + 'doom_type': 83, + 'region': "Industrial Zone (MAP15) Yellow East"}, + 361210: {'name': 'Industrial Zone (MAP15) - Berserk 2', + 'episode': 2, + 'map': 4, + 'index': 178, + 'doom_type': 2023, + 'region': "Industrial Zone (MAP15) Yellow East"}, + 361211: {'name': 'Industrial Zone (MAP15) - Blue keycard', + 'episode': 2, + 'map': 4, + 'index': 337, + 'doom_type': 5, + 'region': "Industrial Zone (MAP15) Yellow West"}, + 361212: {'name': 'Industrial Zone (MAP15) - Mega Armor 2', + 'episode': 2, + 'map': 4, + 'index': 361, + 'doom_type': 2019, + 'region': "Industrial Zone (MAP15) Main"}, + 361213: {'name': 'Industrial Zone (MAP15) - Exit', + 'episode': 2, + 'map': 4, + 'index': -1, + 'doom_type': -1, + 'region': "Industrial Zone (MAP15) Blue"}, + 361214: {'name': 'Suburbs (MAP16) - Megasphere', + 'episode': 2, + 'map': 5, + 'index': 7, + 'doom_type': 83, + 'region': "Suburbs (MAP16) Main"}, + 361215: {'name': 'Suburbs (MAP16) - Super Shotgun', + 'episode': 2, + 'map': 5, + 'index': 11, + 'doom_type': 82, + 'region': "Suburbs (MAP16) Main"}, + 361216: {'name': 'Suburbs (MAP16) - Chaingun', + 'episode': 2, + 'map': 5, + 'index': 15, + 'doom_type': 2002, + 'region': "Suburbs (MAP16) Main"}, + 361217: {'name': 'Suburbs (MAP16) - Backpack', + 'episode': 2, + 'map': 5, + 'index': 53, + 'doom_type': 8, + 'region': "Suburbs (MAP16) Main"}, + 361218: {'name': 'Suburbs (MAP16) - Rocket launcher', + 'episode': 2, + 'map': 5, + 'index': 59, + 'doom_type': 2003, + 'region': "Suburbs (MAP16) Main"}, + 361219: {'name': 'Suburbs (MAP16) - Berserk', + 'episode': 2, + 'map': 5, + 'index': 60, + 'doom_type': 2023, + 'region': "Suburbs (MAP16) Main"}, + 361220: {'name': 'Suburbs (MAP16) - Plasma gun', + 'episode': 2, + 'map': 5, + 'index': 62, + 'doom_type': 2004, + 'region': "Suburbs (MAP16) Blue"}, + 361221: {'name': 'Suburbs (MAP16) - Plasma gun 2', + 'episode': 2, + 'map': 5, + 'index': 63, + 'doom_type': 2004, + 'region': "Suburbs (MAP16) Blue"}, + 361222: {'name': 'Suburbs (MAP16) - Plasma gun 3', + 'episode': 2, + 'map': 5, + 'index': 64, + 'doom_type': 2004, + 'region': "Suburbs (MAP16) Blue"}, + 361223: {'name': 'Suburbs (MAP16) - Plasma gun 4', + 'episode': 2, + 'map': 5, + 'index': 65, + 'doom_type': 2004, + 'region': "Suburbs (MAP16) Blue"}, + 361224: {'name': 'Suburbs (MAP16) - BFG9000', + 'episode': 2, + 'map': 5, + 'index': 169, + 'doom_type': 2006, + 'region': "Suburbs (MAP16) Main"}, + 361225: {'name': 'Suburbs (MAP16) - Shotgun', + 'episode': 2, + 'map': 5, + 'index': 182, + 'doom_type': 2001, + 'region': "Suburbs (MAP16) Main"}, + 361226: {'name': 'Suburbs (MAP16) - Supercharge', + 'episode': 2, + 'map': 5, + 'index': 185, + 'doom_type': 2013, + 'region': "Suburbs (MAP16) Main"}, + 361227: {'name': 'Suburbs (MAP16) - Blue skull key', + 'episode': 2, + 'map': 5, + 'index': 186, + 'doom_type': 40, + 'region': "Suburbs (MAP16) Main"}, + 361228: {'name': 'Suburbs (MAP16) - Invulnerability', + 'episode': 2, + 'map': 5, + 'index': 221, + 'doom_type': 2022, + 'region': "Suburbs (MAP16) Main"}, + 361229: {'name': 'Suburbs (MAP16) - Partial invisibility', + 'episode': 2, + 'map': 5, + 'index': 231, + 'doom_type': 2024, + 'region': "Suburbs (MAP16) Main"}, + 361230: {'name': 'Suburbs (MAP16) - Red skull key', + 'episode': 2, + 'map': 5, + 'index': 236, + 'doom_type': 38, + 'region': "Suburbs (MAP16) Blue"}, + 361231: {'name': 'Suburbs (MAP16) - Exit', + 'episode': 2, + 'map': 5, + 'index': -1, + 'doom_type': -1, + 'region': "Suburbs (MAP16) Red"}, + 361232: {'name': 'Tenements (MAP17) - Armor', + 'episode': 2, + 'map': 6, + 'index': 1, + 'doom_type': 2018, + 'region': "Tenements (MAP17) Red"}, + 361233: {'name': 'Tenements (MAP17) - Supercharge', + 'episode': 2, + 'map': 6, + 'index': 7, + 'doom_type': 2013, + 'region': "Tenements (MAP17) Yellow"}, + 361234: {'name': 'Tenements (MAP17) - Shotgun', + 'episode': 2, + 'map': 6, + 'index': 18, + 'doom_type': 2001, + 'region': "Tenements (MAP17) Main"}, + 361235: {'name': 'Tenements (MAP17) - Red keycard', + 'episode': 2, + 'map': 6, + 'index': 34, + 'doom_type': 13, + 'region': "Tenements (MAP17) Main"}, + 361236: {'name': 'Tenements (MAP17) - Blue keycard', + 'episode': 2, + 'map': 6, + 'index': 69, + 'doom_type': 5, + 'region': "Tenements (MAP17) Red"}, + 361237: {'name': 'Tenements (MAP17) - Supercharge 2', + 'episode': 2, + 'map': 6, + 'index': 75, + 'doom_type': 2013, + 'region': "Tenements (MAP17) Blue"}, + 361238: {'name': 'Tenements (MAP17) - Yellow skull key', + 'episode': 2, + 'map': 6, + 'index': 76, + 'doom_type': 39, + 'region': "Tenements (MAP17) Blue"}, + 361239: {'name': 'Tenements (MAP17) - Rocket launcher', + 'episode': 2, + 'map': 6, + 'index': 77, + 'doom_type': 2003, + 'region': "Tenements (MAP17) Blue"}, + 361240: {'name': 'Tenements (MAP17) - Partial invisibility', + 'episode': 2, + 'map': 6, + 'index': 81, + 'doom_type': 2024, + 'region': "Tenements (MAP17) Blue"}, + 361241: {'name': 'Tenements (MAP17) - Chaingun', + 'episode': 2, + 'map': 6, + 'index': 92, + 'doom_type': 2002, + 'region': "Tenements (MAP17) Red"}, + 361242: {'name': 'Tenements (MAP17) - BFG9000', + 'episode': 2, + 'map': 6, + 'index': 102, + 'doom_type': 2006, + 'region': "Tenements (MAP17) Main"}, + 361243: {'name': 'Tenements (MAP17) - Plasma gun', + 'episode': 2, + 'map': 6, + 'index': 114, + 'doom_type': 2004, + 'region': "Tenements (MAP17) Yellow"}, + 361244: {'name': 'Tenements (MAP17) - Mega Armor', + 'episode': 2, + 'map': 6, + 'index': 168, + 'doom_type': 2019, + 'region': "Tenements (MAP17) Red"}, + 361245: {'name': 'Tenements (MAP17) - Armor 2', + 'episode': 2, + 'map': 6, + 'index': 179, + 'doom_type': 2018, + 'region': "Tenements (MAP17) Red"}, + 361246: {'name': 'Tenements (MAP17) - Berserk', + 'episode': 2, + 'map': 6, + 'index': 218, + 'doom_type': 2023, + 'region': "Tenements (MAP17) Red"}, + 361247: {'name': 'Tenements (MAP17) - Backpack', + 'episode': 2, + 'map': 6, + 'index': 261, + 'doom_type': 8, + 'region': "Tenements (MAP17) Blue"}, + 361248: {'name': 'Tenements (MAP17) - Megasphere', + 'episode': 2, + 'map': 6, + 'index': 419, + 'doom_type': 83, + 'region': "Tenements (MAP17) Yellow"}, + 361249: {'name': 'Tenements (MAP17) - Exit', + 'episode': 2, + 'map': 6, + 'index': -1, + 'doom_type': -1, + 'region': "Tenements (MAP17) Yellow"}, + 361250: {'name': 'The Courtyard (MAP18) - Shotgun', + 'episode': 2, + 'map': 7, + 'index': 12, + 'doom_type': 2001, + 'region': "The Courtyard (MAP18) Main"}, + 361251: {'name': 'The Courtyard (MAP18) - Plasma gun', + 'episode': 2, + 'map': 7, + 'index': 36, + 'doom_type': 2004, + 'region': "The Courtyard (MAP18) Main"}, + 361252: {'name': 'The Courtyard (MAP18) - Armor', + 'episode': 2, + 'map': 7, + 'index': 48, + 'doom_type': 2018, + 'region': "The Courtyard (MAP18) Main"}, + 361253: {'name': 'The Courtyard (MAP18) - Berserk', + 'episode': 2, + 'map': 7, + 'index': 52, + 'doom_type': 2023, + 'region': "The Courtyard (MAP18) Main"}, + 361254: {'name': 'The Courtyard (MAP18) - Chaingun', + 'episode': 2, + 'map': 7, + 'index': 95, + 'doom_type': 2002, + 'region': "The Courtyard (MAP18) Main"}, + 361255: {'name': 'The Courtyard (MAP18) - Rocket launcher', + 'episode': 2, + 'map': 7, + 'index': 130, + 'doom_type': 2003, + 'region': "The Courtyard (MAP18) Main"}, + 361256: {'name': 'The Courtyard (MAP18) - Partial invisibility', + 'episode': 2, + 'map': 7, + 'index': 170, + 'doom_type': 2024, + 'region': "The Courtyard (MAP18) Main"}, + 361257: {'name': 'The Courtyard (MAP18) - Partial invisibility 2', + 'episode': 2, + 'map': 7, + 'index': 171, + 'doom_type': 2024, + 'region': "The Courtyard (MAP18) Main"}, + 361258: {'name': 'The Courtyard (MAP18) - Backpack', + 'episode': 2, + 'map': 7, + 'index': 198, + 'doom_type': 8, + 'region': "The Courtyard (MAP18) Main"}, + 361259: {'name': 'The Courtyard (MAP18) - Supercharge', + 'episode': 2, + 'map': 7, + 'index': 218, + 'doom_type': 2013, + 'region': "The Courtyard (MAP18) Main"}, + 361260: {'name': 'The Courtyard (MAP18) - Invulnerability', + 'episode': 2, + 'map': 7, + 'index': 228, + 'doom_type': 2022, + 'region': "The Courtyard (MAP18) Main"}, + 361261: {'name': 'The Courtyard (MAP18) - Invulnerability 2', + 'episode': 2, + 'map': 7, + 'index': 229, + 'doom_type': 2022, + 'region': "The Courtyard (MAP18) Main"}, + 361262: {'name': 'The Courtyard (MAP18) - Yellow skull key', + 'episode': 2, + 'map': 7, + 'index': 254, + 'doom_type': 39, + 'region': "The Courtyard (MAP18) Main"}, + 361263: {'name': 'The Courtyard (MAP18) - Blue skull key', + 'episode': 2, + 'map': 7, + 'index': 268, + 'doom_type': 40, + 'region': "The Courtyard (MAP18) Yellow"}, + 361264: {'name': 'The Courtyard (MAP18) - BFG9000', + 'episode': 2, + 'map': 7, + 'index': 400, + 'doom_type': 2006, + 'region': "The Courtyard (MAP18) Main"}, + 361265: {'name': 'The Courtyard (MAP18) - Computer area map', + 'episode': 2, + 'map': 7, + 'index': 458, + 'doom_type': 2026, + 'region': "The Courtyard (MAP18) Main"}, + 361266: {'name': 'The Courtyard (MAP18) - Super Shotgun', + 'episode': 2, + 'map': 7, + 'index': 461, + 'doom_type': 82, + 'region': "The Courtyard (MAP18) Main"}, + 361267: {'name': 'The Courtyard (MAP18) - Exit', + 'episode': 2, + 'map': 7, + 'index': -1, + 'doom_type': -1, + 'region': "The Courtyard (MAP18) Blue"}, + 361268: {'name': 'The Citadel (MAP19) - Armor', + 'episode': 2, + 'map': 8, + 'index': 64, + 'doom_type': 2018, + 'region': "The Citadel (MAP19) Main"}, + 361269: {'name': 'The Citadel (MAP19) - Chaingun', + 'episode': 2, + 'map': 8, + 'index': 99, + 'doom_type': 2002, + 'region': "The Citadel (MAP19) Main"}, + 361270: {'name': 'The Citadel (MAP19) - Berserk', + 'episode': 2, + 'map': 8, + 'index': 116, + 'doom_type': 2023, + 'region': "The Citadel (MAP19) Main"}, + 361271: {'name': 'The Citadel (MAP19) - Mega Armor', + 'episode': 2, + 'map': 8, + 'index': 127, + 'doom_type': 2019, + 'region': "The Citadel (MAP19) Main"}, + 361272: {'name': 'The Citadel (MAP19) - Supercharge', + 'episode': 2, + 'map': 8, + 'index': 174, + 'doom_type': 2013, + 'region': "The Citadel (MAP19) Main"}, + 361273: {'name': 'The Citadel (MAP19) - Armor 2', + 'episode': 2, + 'map': 8, + 'index': 223, + 'doom_type': 2018, + 'region': "The Citadel (MAP19) Main"}, + 361274: {'name': 'The Citadel (MAP19) - Backpack', + 'episode': 2, + 'map': 8, + 'index': 232, + 'doom_type': 8, + 'region': "The Citadel (MAP19) Main"}, + 361275: {'name': 'The Citadel (MAP19) - Invulnerability', + 'episode': 2, + 'map': 8, + 'index': 315, + 'doom_type': 2022, + 'region': "The Citadel (MAP19) Main"}, + 361276: {'name': 'The Citadel (MAP19) - Blue skull key', + 'episode': 2, + 'map': 8, + 'index': 370, + 'doom_type': 40, + 'region': "The Citadel (MAP19) Main"}, + 361277: {'name': 'The Citadel (MAP19) - Partial invisibility', + 'episode': 2, + 'map': 8, + 'index': 403, + 'doom_type': 2024, + 'region': "The Citadel (MAP19) Main"}, + 361278: {'name': 'The Citadel (MAP19) - Red skull key', + 'episode': 2, + 'map': 8, + 'index': 404, + 'doom_type': 38, + 'region': "The Citadel (MAP19) Main"}, + 361279: {'name': 'The Citadel (MAP19) - Yellow skull key', + 'episode': 2, + 'map': 8, + 'index': 405, + 'doom_type': 39, + 'region': "The Citadel (MAP19) Main"}, + 361280: {'name': 'The Citadel (MAP19) - Computer area map', + 'episode': 2, + 'map': 8, + 'index': 415, + 'doom_type': 2026, + 'region': "The Citadel (MAP19) Main"}, + 361281: {'name': 'The Citadel (MAP19) - Rocket launcher', + 'episode': 2, + 'map': 8, + 'index': 416, + 'doom_type': 2003, + 'region': "The Citadel (MAP19) Main"}, + 361282: {'name': 'The Citadel (MAP19) - Super Shotgun', + 'episode': 2, + 'map': 8, + 'index': 431, + 'doom_type': 82, + 'region': "The Citadel (MAP19) Main"}, + 361283: {'name': 'The Citadel (MAP19) - Exit', + 'episode': 2, + 'map': 8, + 'index': -1, + 'doom_type': -1, + 'region': "The Citadel (MAP19) Red"}, + 361284: {'name': 'Gotcha! (MAP20) - Mega Armor', + 'episode': 2, + 'map': 9, + 'index': 9, + 'doom_type': 2019, + 'region': "Gotcha! (MAP20) Main"}, + 361285: {'name': 'Gotcha! (MAP20) - Rocket launcher', + 'episode': 2, + 'map': 9, + 'index': 10, + 'doom_type': 2003, + 'region': "Gotcha! (MAP20) Main"}, + 361286: {'name': 'Gotcha! (MAP20) - Supercharge', + 'episode': 2, + 'map': 9, + 'index': 12, + 'doom_type': 2013, + 'region': "Gotcha! (MAP20) Main"}, + 361287: {'name': 'Gotcha! (MAP20) - Armor', + 'episode': 2, + 'map': 9, + 'index': 33, + 'doom_type': 2018, + 'region': "Gotcha! (MAP20) Main"}, + 361288: {'name': 'Gotcha! (MAP20) - Megasphere', + 'episode': 2, + 'map': 9, + 'index': 43, + 'doom_type': 83, + 'region': "Gotcha! (MAP20) Main"}, + 361289: {'name': 'Gotcha! (MAP20) - Armor 2', + 'episode': 2, + 'map': 9, + 'index': 47, + 'doom_type': 2018, + 'region': "Gotcha! (MAP20) Main"}, + 361290: {'name': 'Gotcha! (MAP20) - Super Shotgun', + 'episode': 2, + 'map': 9, + 'index': 54, + 'doom_type': 82, + 'region': "Gotcha! (MAP20) Main"}, + 361291: {'name': 'Gotcha! (MAP20) - Plasma gun', + 'episode': 2, + 'map': 9, + 'index': 70, + 'doom_type': 2004, + 'region': "Gotcha! (MAP20) Main"}, + 361292: {'name': 'Gotcha! (MAP20) - Mega Armor 2', + 'episode': 2, + 'map': 9, + 'index': 96, + 'doom_type': 2019, + 'region': "Gotcha! (MAP20) Main"}, + 361293: {'name': 'Gotcha! (MAP20) - Berserk', + 'episode': 2, + 'map': 9, + 'index': 109, + 'doom_type': 2023, + 'region': "Gotcha! (MAP20) Main"}, + 361294: {'name': 'Gotcha! (MAP20) - Supercharge 2', + 'episode': 2, + 'map': 9, + 'index': 119, + 'doom_type': 2013, + 'region': "Gotcha! (MAP20) Main"}, + 361295: {'name': 'Gotcha! (MAP20) - Supercharge 3', + 'episode': 2, + 'map': 9, + 'index': 122, + 'doom_type': 2013, + 'region': "Gotcha! (MAP20) Main"}, + 361296: {'name': 'Gotcha! (MAP20) - BFG9000', + 'episode': 2, + 'map': 9, + 'index': 142, + 'doom_type': 2006, + 'region': "Gotcha! (MAP20) Main"}, + 361297: {'name': 'Gotcha! (MAP20) - Supercharge 4', + 'episode': 2, + 'map': 9, + 'index': 145, + 'doom_type': 2013, + 'region': "Gotcha! (MAP20) Main"}, + 361298: {'name': 'Gotcha! (MAP20) - Exit', + 'episode': 2, + 'map': 9, + 'index': -1, + 'doom_type': -1, + 'region': "Gotcha! (MAP20) Main"}, + 361299: {'name': 'Nirvana (MAP21) - Super Shotgun', + 'episode': 3, + 'map': 1, + 'index': 70, + 'doom_type': 82, + 'region': "Nirvana (MAP21) Main"}, + 361300: {'name': 'Nirvana (MAP21) - Rocket launcher', + 'episode': 3, + 'map': 1, + 'index': 76, + 'doom_type': 2003, + 'region': "Nirvana (MAP21) Main"}, + 361301: {'name': 'Nirvana (MAP21) - Yellow skull key', + 'episode': 3, + 'map': 1, + 'index': 108, + 'doom_type': 39, + 'region': "Nirvana (MAP21) Main"}, + 361302: {'name': 'Nirvana (MAP21) - Backpack', + 'episode': 3, + 'map': 1, + 'index': 109, + 'doom_type': 8, + 'region': "Nirvana (MAP21) Main"}, + 361303: {'name': 'Nirvana (MAP21) - Megasphere', + 'episode': 3, + 'map': 1, + 'index': 112, + 'doom_type': 83, + 'region': "Nirvana (MAP21) Main"}, + 361304: {'name': 'Nirvana (MAP21) - Invulnerability', + 'episode': 3, + 'map': 1, + 'index': 194, + 'doom_type': 2022, + 'region': "Nirvana (MAP21) Yellow"}, + 361305: {'name': 'Nirvana (MAP21) - Blue skull key', + 'episode': 3, + 'map': 1, + 'index': 199, + 'doom_type': 40, + 'region': "Nirvana (MAP21) Yellow"}, + 361306: {'name': 'Nirvana (MAP21) - Red skull key', + 'episode': 3, + 'map': 1, + 'index': 215, + 'doom_type': 38, + 'region': "Nirvana (MAP21) Yellow"}, + 361307: {'name': 'Nirvana (MAP21) - Exit', + 'episode': 3, + 'map': 1, + 'index': -1, + 'doom_type': -1, + 'region': "Nirvana (MAP21) Magenta"}, + 361308: {'name': 'The Catacombs (MAP22) - Rocket launcher', + 'episode': 3, + 'map': 2, + 'index': 4, + 'doom_type': 2003, + 'region': "The Catacombs (MAP22) Main"}, + 361309: {'name': 'The Catacombs (MAP22) - Blue skull key', + 'episode': 3, + 'map': 2, + 'index': 5, + 'doom_type': 40, + 'region': "The Catacombs (MAP22) Main"}, + 361310: {'name': 'The Catacombs (MAP22) - Red skull key', + 'episode': 3, + 'map': 2, + 'index': 12, + 'doom_type': 38, + 'region': "The Catacombs (MAP22) Blue"}, + 361311: {'name': 'The Catacombs (MAP22) - Shotgun', + 'episode': 3, + 'map': 2, + 'index': 28, + 'doom_type': 2001, + 'region': "The Catacombs (MAP22) Main"}, + 361312: {'name': 'The Catacombs (MAP22) - Berserk', + 'episode': 3, + 'map': 2, + 'index': 45, + 'doom_type': 2023, + 'region': "The Catacombs (MAP22) Main"}, + 361313: {'name': 'The Catacombs (MAP22) - Plasma gun', + 'episode': 3, + 'map': 2, + 'index': 83, + 'doom_type': 2004, + 'region': "The Catacombs (MAP22) Main"}, + 361314: {'name': 'The Catacombs (MAP22) - Supercharge', + 'episode': 3, + 'map': 2, + 'index': 118, + 'doom_type': 2013, + 'region': "The Catacombs (MAP22) Main"}, + 361315: {'name': 'The Catacombs (MAP22) - Armor', + 'episode': 3, + 'map': 2, + 'index': 119, + 'doom_type': 2018, + 'region': "The Catacombs (MAP22) Main"}, + 361316: {'name': 'The Catacombs (MAP22) - Exit', + 'episode': 3, + 'map': 2, + 'index': -1, + 'doom_type': -1, + 'region': "The Catacombs (MAP22) Red"}, + 361317: {'name': 'Barrels o Fun (MAP23) - Shotgun', + 'episode': 3, + 'map': 3, + 'index': 136, + 'doom_type': 2001, + 'region': "Barrels o Fun (MAP23) Main"}, + 361318: {'name': 'Barrels o Fun (MAP23) - Berserk', + 'episode': 3, + 'map': 3, + 'index': 222, + 'doom_type': 2023, + 'region': "Barrels o Fun (MAP23) Main"}, + 361319: {'name': 'Barrels o Fun (MAP23) - Backpack', + 'episode': 3, + 'map': 3, + 'index': 223, + 'doom_type': 8, + 'region': "Barrels o Fun (MAP23) Main"}, + 361320: {'name': 'Barrels o Fun (MAP23) - Computer area map', + 'episode': 3, + 'map': 3, + 'index': 224, + 'doom_type': 2026, + 'region': "Barrels o Fun (MAP23) Main"}, + 361321: {'name': 'Barrels o Fun (MAP23) - Armor', + 'episode': 3, + 'map': 3, + 'index': 249, + 'doom_type': 2018, + 'region': "Barrels o Fun (MAP23) Main"}, + 361322: {'name': 'Barrels o Fun (MAP23) - Rocket launcher', + 'episode': 3, + 'map': 3, + 'index': 264, + 'doom_type': 2003, + 'region': "Barrels o Fun (MAP23) Main"}, + 361323: {'name': 'Barrels o Fun (MAP23) - Megasphere', + 'episode': 3, + 'map': 3, + 'index': 266, + 'doom_type': 83, + 'region': "Barrels o Fun (MAP23) Main"}, + 361324: {'name': 'Barrels o Fun (MAP23) - Supercharge', + 'episode': 3, + 'map': 3, + 'index': 277, + 'doom_type': 2013, + 'region': "Barrels o Fun (MAP23) Main"}, + 361325: {'name': 'Barrels o Fun (MAP23) - Backpack 2', + 'episode': 3, + 'map': 3, + 'index': 301, + 'doom_type': 8, + 'region': "Barrels o Fun (MAP23) Main"}, + 361326: {'name': 'Barrels o Fun (MAP23) - Yellow skull key', + 'episode': 3, + 'map': 3, + 'index': 307, + 'doom_type': 39, + 'region': "Barrels o Fun (MAP23) Main"}, + 361327: {'name': 'Barrels o Fun (MAP23) - BFG9000', + 'episode': 3, + 'map': 3, + 'index': 342, + 'doom_type': 2006, + 'region': "Barrels o Fun (MAP23) Main"}, + 361328: {'name': 'Barrels o Fun (MAP23) - Exit', + 'episode': 3, + 'map': 3, + 'index': -1, + 'doom_type': -1, + 'region': "Barrels o Fun (MAP23) Yellow"}, + 361329: {'name': 'The Chasm (MAP24) - Plasma gun', + 'episode': 3, + 'map': 4, + 'index': 5, + 'doom_type': 2004, + 'region': "The Chasm (MAP24) Main"}, + 361330: {'name': 'The Chasm (MAP24) - Shotgun', + 'episode': 3, + 'map': 4, + 'index': 6, + 'doom_type': 2001, + 'region': "The Chasm (MAP24) Main"}, + 361331: {'name': 'The Chasm (MAP24) - Invulnerability', + 'episode': 3, + 'map': 4, + 'index': 12, + 'doom_type': 2022, + 'region': "The Chasm (MAP24) Main"}, + 361332: {'name': 'The Chasm (MAP24) - Rocket launcher', + 'episode': 3, + 'map': 4, + 'index': 22, + 'doom_type': 2003, + 'region': "The Chasm (MAP24) Main"}, + 361333: {'name': 'The Chasm (MAP24) - Blue keycard', + 'episode': 3, + 'map': 4, + 'index': 23, + 'doom_type': 5, + 'region': "The Chasm (MAP24) Main"}, + 361334: {'name': 'The Chasm (MAP24) - Backpack', + 'episode': 3, + 'map': 4, + 'index': 31, + 'doom_type': 8, + 'region': "The Chasm (MAP24) Main"}, + 361335: {'name': 'The Chasm (MAP24) - Berserk', + 'episode': 3, + 'map': 4, + 'index': 79, + 'doom_type': 2023, + 'region': "The Chasm (MAP24) Main"}, + 361336: {'name': 'The Chasm (MAP24) - Berserk 2', + 'episode': 3, + 'map': 4, + 'index': 155, + 'doom_type': 2023, + 'region': "The Chasm (MAP24) Main"}, + 361337: {'name': 'The Chasm (MAP24) - Armor', + 'episode': 3, + 'map': 4, + 'index': 169, + 'doom_type': 2018, + 'region': "The Chasm (MAP24) Main"}, + 361338: {'name': 'The Chasm (MAP24) - Red keycard', + 'episode': 3, + 'map': 4, + 'index': 261, + 'doom_type': 13, + 'region': "The Chasm (MAP24) Main"}, + 361339: {'name': 'The Chasm (MAP24) - BFG9000', + 'episode': 3, + 'map': 4, + 'index': 295, + 'doom_type': 2006, + 'region': "The Chasm (MAP24) Main"}, + 361340: {'name': 'The Chasm (MAP24) - Super Shotgun', + 'episode': 3, + 'map': 4, + 'index': 353, + 'doom_type': 82, + 'region': "The Chasm (MAP24) Main"}, + 361341: {'name': 'The Chasm (MAP24) - Megasphere', + 'episode': 3, + 'map': 4, + 'index': 355, + 'doom_type': 83, + 'region': "The Chasm (MAP24) Main"}, + 361342: {'name': 'The Chasm (MAP24) - Megasphere 2', + 'episode': 3, + 'map': 4, + 'index': 362, + 'doom_type': 83, + 'region': "The Chasm (MAP24) Main"}, + 361343: {'name': 'The Chasm (MAP24) - Exit', + 'episode': 3, + 'map': 4, + 'index': -1, + 'doom_type': -1, + 'region': "The Chasm (MAP24) Red"}, + 361344: {'name': 'Bloodfalls (MAP25) - Super Shotgun', + 'episode': 3, + 'map': 5, + 'index': 6, + 'doom_type': 82, + 'region': "Bloodfalls (MAP25) Main"}, + 361345: {'name': 'Bloodfalls (MAP25) - Partial invisibility', + 'episode': 3, + 'map': 5, + 'index': 7, + 'doom_type': 2024, + 'region': "Bloodfalls (MAP25) Blue"}, + 361346: {'name': 'Bloodfalls (MAP25) - Megasphere', + 'episode': 3, + 'map': 5, + 'index': 23, + 'doom_type': 83, + 'region': "Bloodfalls (MAP25) Main"}, + 361347: {'name': 'Bloodfalls (MAP25) - BFG9000', + 'episode': 3, + 'map': 5, + 'index': 34, + 'doom_type': 2006, + 'region': "Bloodfalls (MAP25) Blue"}, + 361348: {'name': 'Bloodfalls (MAP25) - Mega Armor', + 'episode': 3, + 'map': 5, + 'index': 103, + 'doom_type': 2019, + 'region': "Bloodfalls (MAP25) Main"}, + 361349: {'name': 'Bloodfalls (MAP25) - Armor', + 'episode': 3, + 'map': 5, + 'index': 104, + 'doom_type': 2018, + 'region': "Bloodfalls (MAP25) Main"}, + 361350: {'name': 'Bloodfalls (MAP25) - Blue skull key', + 'episode': 3, + 'map': 5, + 'index': 106, + 'doom_type': 40, + 'region': "Bloodfalls (MAP25) Main"}, + 361351: {'name': 'Bloodfalls (MAP25) - Chaingun', + 'episode': 3, + 'map': 5, + 'index': 150, + 'doom_type': 2002, + 'region': "Bloodfalls (MAP25) Main"}, + 361352: {'name': 'Bloodfalls (MAP25) - Plasma gun', + 'episode': 3, + 'map': 5, + 'index': 169, + 'doom_type': 2004, + 'region': "Bloodfalls (MAP25) Main"}, + 361353: {'name': 'Bloodfalls (MAP25) - BFG9000 2', + 'episode': 3, + 'map': 5, + 'index': 186, + 'doom_type': 2006, + 'region': "Bloodfalls (MAP25) Main"}, + 361354: {'name': 'Bloodfalls (MAP25) - Rocket launcher', + 'episode': 3, + 'map': 5, + 'index': 236, + 'doom_type': 2003, + 'region': "Bloodfalls (MAP25) Main"}, + 361355: {'name': 'Bloodfalls (MAP25) - Exit', + 'episode': 3, + 'map': 5, + 'index': -1, + 'doom_type': -1, + 'region': "Bloodfalls (MAP25) Blue"}, + 361356: {'name': 'The Abandoned Mines (MAP26) - Blue keycard', + 'episode': 3, + 'map': 6, + 'index': 20, + 'doom_type': 5, + 'region': "The Abandoned Mines (MAP26) Red"}, + 361357: {'name': 'The Abandoned Mines (MAP26) - Super Shotgun', + 'episode': 3, + 'map': 6, + 'index': 21, + 'doom_type': 82, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361358: {'name': 'The Abandoned Mines (MAP26) - Rocket launcher', + 'episode': 3, + 'map': 6, + 'index': 49, + 'doom_type': 2003, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361359: {'name': 'The Abandoned Mines (MAP26) - Mega Armor', + 'episode': 3, + 'map': 6, + 'index': 95, + 'doom_type': 2019, + 'region': "The Abandoned Mines (MAP26) Red"}, + 361360: {'name': 'The Abandoned Mines (MAP26) - Plasma gun', + 'episode': 3, + 'map': 6, + 'index': 107, + 'doom_type': 2004, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361361: {'name': 'The Abandoned Mines (MAP26) - Supercharge', + 'episode': 3, + 'map': 6, + 'index': 154, + 'doom_type': 2013, + 'region': "The Abandoned Mines (MAP26) Red"}, + 361362: {'name': 'The Abandoned Mines (MAP26) - Chaingun', + 'episode': 3, + 'map': 6, + 'index': 155, + 'doom_type': 2002, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361363: {'name': 'The Abandoned Mines (MAP26) - Partial invisibility', + 'episode': 3, + 'map': 6, + 'index': 159, + 'doom_type': 2024, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361364: {'name': 'The Abandoned Mines (MAP26) - Armor', + 'episode': 3, + 'map': 6, + 'index': 170, + 'doom_type': 2018, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361365: {'name': 'The Abandoned Mines (MAP26) - Red keycard', + 'episode': 3, + 'map': 6, + 'index': 182, + 'doom_type': 13, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361366: {'name': 'The Abandoned Mines (MAP26) - Yellow keycard', + 'episode': 3, + 'map': 6, + 'index': 229, + 'doom_type': 6, + 'region': "The Abandoned Mines (MAP26) Blue"}, + 361367: {'name': 'The Abandoned Mines (MAP26) - Backpack', + 'episode': 3, + 'map': 6, + 'index': 254, + 'doom_type': 8, + 'region': "The Abandoned Mines (MAP26) Main"}, + 361368: {'name': 'The Abandoned Mines (MAP26) - Exit', + 'episode': 3, + 'map': 6, + 'index': -1, + 'doom_type': -1, + 'region': "The Abandoned Mines (MAP26) Yellow"}, + 361369: {'name': 'Monster Condo (MAP27) - Rocket launcher', + 'episode': 3, + 'map': 7, + 'index': 4, + 'doom_type': 2003, + 'region': "Monster Condo (MAP27) Main"}, + 361370: {'name': 'Monster Condo (MAP27) - Partial invisibility', + 'episode': 3, + 'map': 7, + 'index': 51, + 'doom_type': 2024, + 'region': "Monster Condo (MAP27) Main"}, + 361371: {'name': 'Monster Condo (MAP27) - Plasma gun', + 'episode': 3, + 'map': 7, + 'index': 58, + 'doom_type': 2004, + 'region': "Monster Condo (MAP27) Main"}, + 361372: {'name': 'Monster Condo (MAP27) - Invulnerability', + 'episode': 3, + 'map': 7, + 'index': 60, + 'doom_type': 2022, + 'region': "Monster Condo (MAP27) Main"}, + 361373: {'name': 'Monster Condo (MAP27) - Armor', + 'episode': 3, + 'map': 7, + 'index': 86, + 'doom_type': 2018, + 'region': "Monster Condo (MAP27) Main"}, + 361374: {'name': 'Monster Condo (MAP27) - Backpack', + 'episode': 3, + 'map': 7, + 'index': 105, + 'doom_type': 8, + 'region': "Monster Condo (MAP27) Main"}, + 361375: {'name': 'Monster Condo (MAP27) - Invulnerability 2', + 'episode': 3, + 'map': 7, + 'index': 107, + 'doom_type': 2022, + 'region': "Monster Condo (MAP27) Main"}, + 361376: {'name': 'Monster Condo (MAP27) - Partial invisibility 2', + 'episode': 3, + 'map': 7, + 'index': 122, + 'doom_type': 2024, + 'region': "Monster Condo (MAP27) Main"}, + 361377: {'name': 'Monster Condo (MAP27) - Supercharge', + 'episode': 3, + 'map': 7, + 'index': 236, + 'doom_type': 2013, + 'region': "Monster Condo (MAP27) Main"}, + 361378: {'name': 'Monster Condo (MAP27) - Armor 2', + 'episode': 3, + 'map': 7, + 'index': 239, + 'doom_type': 2018, + 'region': "Monster Condo (MAP27) Main"}, + 361379: {'name': 'Monster Condo (MAP27) - Chaingun', + 'episode': 3, + 'map': 7, + 'index': 251, + 'doom_type': 2002, + 'region': "Monster Condo (MAP27) Main"}, + 361380: {'name': 'Monster Condo (MAP27) - BFG9000', + 'episode': 3, + 'map': 7, + 'index': 279, + 'doom_type': 2006, + 'region': "Monster Condo (MAP27) Main"}, + 361381: {'name': 'Monster Condo (MAP27) - Backpack 2', + 'episode': 3, + 'map': 7, + 'index': 285, + 'doom_type': 8, + 'region': "Monster Condo (MAP27) Main"}, + 361382: {'name': 'Monster Condo (MAP27) - Backpack 3', + 'episode': 3, + 'map': 7, + 'index': 286, + 'doom_type': 8, + 'region': "Monster Condo (MAP27) Main"}, + 361383: {'name': 'Monster Condo (MAP27) - Backpack 4', + 'episode': 3, + 'map': 7, + 'index': 287, + 'doom_type': 8, + 'region': "Monster Condo (MAP27) Main"}, + 361384: {'name': 'Monster Condo (MAP27) - Yellow skull key', + 'episode': 3, + 'map': 7, + 'index': 310, + 'doom_type': 39, + 'region': "Monster Condo (MAP27) Main"}, + 361385: {'name': 'Monster Condo (MAP27) - Red skull key', + 'episode': 3, + 'map': 7, + 'index': 364, + 'doom_type': 38, + 'region': "Monster Condo (MAP27) Blue"}, + 361386: {'name': 'Monster Condo (MAP27) - Supercharge 2', + 'episode': 3, + 'map': 7, + 'index': 365, + 'doom_type': 2013, + 'region': "Monster Condo (MAP27) Blue"}, + 361387: {'name': 'Monster Condo (MAP27) - Blue skull key', + 'episode': 3, + 'map': 7, + 'index': 382, + 'doom_type': 40, + 'region': "Monster Condo (MAP27) Yellow"}, + 361388: {'name': 'Monster Condo (MAP27) - Supercharge 3', + 'episode': 3, + 'map': 7, + 'index': 392, + 'doom_type': 2013, + 'region': "Monster Condo (MAP27) Yellow"}, + 361389: {'name': 'Monster Condo (MAP27) - Computer area map', + 'episode': 3, + 'map': 7, + 'index': 393, + 'doom_type': 2026, + 'region': "Monster Condo (MAP27) Yellow"}, + 361390: {'name': 'Monster Condo (MAP27) - Berserk', + 'episode': 3, + 'map': 7, + 'index': 394, + 'doom_type': 2023, + 'region': "Monster Condo (MAP27) Yellow"}, + 361391: {'name': 'Monster Condo (MAP27) - Supercharge 4', + 'episode': 3, + 'map': 7, + 'index': 414, + 'doom_type': 2013, + 'region': "Monster Condo (MAP27) Yellow"}, + 361392: {'name': 'Monster Condo (MAP27) - Supercharge 5', + 'episode': 3, + 'map': 7, + 'index': 424, + 'doom_type': 2013, + 'region': "Monster Condo (MAP27) Yellow"}, + 361393: {'name': 'Monster Condo (MAP27) - Computer area map 2', + 'episode': 3, + 'map': 7, + 'index': 425, + 'doom_type': 2026, + 'region': "Monster Condo (MAP27) Yellow"}, + 361394: {'name': 'Monster Condo (MAP27) - Berserk 2', + 'episode': 3, + 'map': 7, + 'index': 426, + 'doom_type': 2023, + 'region': "Monster Condo (MAP27) Yellow"}, + 361395: {'name': 'Monster Condo (MAP27) - Partial invisibility 3', + 'episode': 3, + 'map': 7, + 'index': 454, + 'doom_type': 2024, + 'region': "Monster Condo (MAP27) Yellow"}, + 361396: {'name': 'Monster Condo (MAP27) - Invulnerability 3', + 'episode': 3, + 'map': 7, + 'index': 455, + 'doom_type': 2022, + 'region': "Monster Condo (MAP27) Yellow"}, + 361397: {'name': 'Monster Condo (MAP27) - Chainsaw', + 'episode': 3, + 'map': 7, + 'index': 460, + 'doom_type': 2005, + 'region': "Monster Condo (MAP27) Main"}, + 361398: {'name': 'Monster Condo (MAP27) - Super Shotgun', + 'episode': 3, + 'map': 7, + 'index': 470, + 'doom_type': 82, + 'region': "Monster Condo (MAP27) Main"}, + 361399: {'name': 'Monster Condo (MAP27) - Exit', + 'episode': 3, + 'map': 7, + 'index': -1, + 'doom_type': -1, + 'region': "Monster Condo (MAP27) Red"}, + 361400: {'name': 'The Spirit World (MAP28) - Armor', + 'episode': 3, + 'map': 8, + 'index': 19, + 'doom_type': 2018, + 'region': "The Spirit World (MAP28) Main"}, + 361401: {'name': 'The Spirit World (MAP28) - Chainsaw', + 'episode': 3, + 'map': 8, + 'index': 66, + 'doom_type': 2005, + 'region': "The Spirit World (MAP28) Main"}, + 361402: {'name': 'The Spirit World (MAP28) - Invulnerability', + 'episode': 3, + 'map': 8, + 'index': 76, + 'doom_type': 2022, + 'region': "The Spirit World (MAP28) Main"}, + 361403: {'name': 'The Spirit World (MAP28) - Yellow skull key', + 'episode': 3, + 'map': 8, + 'index': 87, + 'doom_type': 39, + 'region': "The Spirit World (MAP28) Main"}, + 361404: {'name': 'The Spirit World (MAP28) - Supercharge', + 'episode': 3, + 'map': 8, + 'index': 95, + 'doom_type': 2013, + 'region': "The Spirit World (MAP28) Main"}, + 361405: {'name': 'The Spirit World (MAP28) - Chaingun', + 'episode': 3, + 'map': 8, + 'index': 96, + 'doom_type': 2002, + 'region': "The Spirit World (MAP28) Main"}, + 361406: {'name': 'The Spirit World (MAP28) - Rocket launcher', + 'episode': 3, + 'map': 8, + 'index': 124, + 'doom_type': 2003, + 'region': "The Spirit World (MAP28) Main"}, + 361407: {'name': 'The Spirit World (MAP28) - Backpack', + 'episode': 3, + 'map': 8, + 'index': 155, + 'doom_type': 8, + 'region': "The Spirit World (MAP28) Main"}, + 361408: {'name': 'The Spirit World (MAP28) - Backpack 2', + 'episode': 3, + 'map': 8, + 'index': 156, + 'doom_type': 8, + 'region': "The Spirit World (MAP28) Main"}, + 361409: {'name': 'The Spirit World (MAP28) - Backpack 3', + 'episode': 3, + 'map': 8, + 'index': 157, + 'doom_type': 8, + 'region': "The Spirit World (MAP28) Main"}, + 361410: {'name': 'The Spirit World (MAP28) - Backpack 4', + 'episode': 3, + 'map': 8, + 'index': 158, + 'doom_type': 8, + 'region': "The Spirit World (MAP28) Main"}, + 361411: {'name': 'The Spirit World (MAP28) - Berserk', + 'episode': 3, + 'map': 8, + 'index': 159, + 'doom_type': 2023, + 'region': "The Spirit World (MAP28) Main"}, + 361412: {'name': 'The Spirit World (MAP28) - Plasma gun', + 'episode': 3, + 'map': 8, + 'index': 163, + 'doom_type': 2004, + 'region': "The Spirit World (MAP28) Main"}, + 361413: {'name': 'The Spirit World (MAP28) - Invulnerability 2', + 'episode': 3, + 'map': 8, + 'index': 179, + 'doom_type': 2022, + 'region': "The Spirit World (MAP28) Main"}, + 361414: {'name': 'The Spirit World (MAP28) - Invulnerability 3', + 'episode': 3, + 'map': 8, + 'index': 180, + 'doom_type': 2022, + 'region': "The Spirit World (MAP28) Main"}, + 361415: {'name': 'The Spirit World (MAP28) - BFG9000', + 'episode': 3, + 'map': 8, + 'index': 181, + 'doom_type': 2006, + 'region': "The Spirit World (MAP28) Main"}, + 361416: {'name': 'The Spirit World (MAP28) - Megasphere', + 'episode': 3, + 'map': 8, + 'index': 183, + 'doom_type': 83, + 'region': "The Spirit World (MAP28) Main"}, + 361417: {'name': 'The Spirit World (MAP28) - Megasphere 2', + 'episode': 3, + 'map': 8, + 'index': 185, + 'doom_type': 83, + 'region': "The Spirit World (MAP28) Main"}, + 361418: {'name': 'The Spirit World (MAP28) - Invulnerability 4', + 'episode': 3, + 'map': 8, + 'index': 186, + 'doom_type': 2022, + 'region': "The Spirit World (MAP28) Main"}, + 361419: {'name': 'The Spirit World (MAP28) - Invulnerability 5', + 'episode': 3, + 'map': 8, + 'index': 195, + 'doom_type': 2022, + 'region': "The Spirit World (MAP28) Main"}, + 361420: {'name': 'The Spirit World (MAP28) - Super Shotgun', + 'episode': 3, + 'map': 8, + 'index': 214, + 'doom_type': 82, + 'region': "The Spirit World (MAP28) Main"}, + 361421: {'name': 'The Spirit World (MAP28) - Red skull key', + 'episode': 3, + 'map': 8, + 'index': 216, + 'doom_type': 38, + 'region': "The Spirit World (MAP28) Yellow"}, + 361422: {'name': 'The Spirit World (MAP28) - Exit', + 'episode': 3, + 'map': 8, + 'index': -1, + 'doom_type': -1, + 'region': "The Spirit World (MAP28) Red"}, + 361423: {'name': 'The Living End (MAP29) - Chaingun', + 'episode': 3, + 'map': 9, + 'index': 85, + 'doom_type': 2002, + 'region': "The Living End (MAP29) Main"}, + 361424: {'name': 'The Living End (MAP29) - Plasma gun', + 'episode': 3, + 'map': 9, + 'index': 124, + 'doom_type': 2004, + 'region': "The Living End (MAP29) Main"}, + 361425: {'name': 'The Living End (MAP29) - Backpack', + 'episode': 3, + 'map': 9, + 'index': 179, + 'doom_type': 8, + 'region': "The Living End (MAP29) Main"}, + 361426: {'name': 'The Living End (MAP29) - Super Shotgun', + 'episode': 3, + 'map': 9, + 'index': 195, + 'doom_type': 82, + 'region': "The Living End (MAP29) Main"}, + 361427: {'name': 'The Living End (MAP29) - Mega Armor', + 'episode': 3, + 'map': 9, + 'index': 216, + 'doom_type': 2019, + 'region': "The Living End (MAP29) Main"}, + 361428: {'name': 'The Living End (MAP29) - Armor', + 'episode': 3, + 'map': 9, + 'index': 224, + 'doom_type': 2018, + 'region': "The Living End (MAP29) Main"}, + 361429: {'name': 'The Living End (MAP29) - Backpack 2', + 'episode': 3, + 'map': 9, + 'index': 235, + 'doom_type': 8, + 'region': "The Living End (MAP29) Main"}, + 361430: {'name': 'The Living End (MAP29) - Supercharge', + 'episode': 3, + 'map': 9, + 'index': 237, + 'doom_type': 2013, + 'region': "The Living End (MAP29) Main"}, + 361431: {'name': 'The Living End (MAP29) - Berserk', + 'episode': 3, + 'map': 9, + 'index': 241, + 'doom_type': 2023, + 'region': "The Living End (MAP29) Main"}, + 361432: {'name': 'The Living End (MAP29) - Berserk 2', + 'episode': 3, + 'map': 9, + 'index': 263, + 'doom_type': 2023, + 'region': "The Living End (MAP29) Main"}, + 361433: {'name': 'The Living End (MAP29) - Exit', + 'episode': 3, + 'map': 9, + 'index': -1, + 'doom_type': -1, + 'region': "The Living End (MAP29) Main"}, + 361434: {'name': 'Icon of Sin (MAP30) - Supercharge', + 'episode': 3, + 'map': 10, + 'index': 25, + 'doom_type': 2013, + 'region': "Icon of Sin (MAP30) Main"}, + 361435: {'name': 'Icon of Sin (MAP30) - Supercharge 2', + 'episode': 3, + 'map': 10, + 'index': 26, + 'doom_type': 2013, + 'region': "Icon of Sin (MAP30) Main"}, + 361436: {'name': 'Icon of Sin (MAP30) - Supercharge 3', + 'episode': 3, + 'map': 10, + 'index': 28, + 'doom_type': 2013, + 'region': "Icon of Sin (MAP30) Main"}, + 361437: {'name': 'Icon of Sin (MAP30) - Invulnerability', + 'episode': 3, + 'map': 10, + 'index': 29, + 'doom_type': 2022, + 'region': "Icon of Sin (MAP30) Main"}, + 361438: {'name': 'Icon of Sin (MAP30) - Invulnerability 2', + 'episode': 3, + 'map': 10, + 'index': 30, + 'doom_type': 2022, + 'region': "Icon of Sin (MAP30) Main"}, + 361439: {'name': 'Icon of Sin (MAP30) - Invulnerability 3', + 'episode': 3, + 'map': 10, + 'index': 31, + 'doom_type': 2022, + 'region': "Icon of Sin (MAP30) Main"}, + 361440: {'name': 'Icon of Sin (MAP30) - Invulnerability 4', + 'episode': 3, + 'map': 10, + 'index': 32, + 'doom_type': 2022, + 'region': "Icon of Sin (MAP30) Main"}, + 361441: {'name': 'Icon of Sin (MAP30) - BFG9000', + 'episode': 3, + 'map': 10, + 'index': 40, + 'doom_type': 2006, + 'region': "Icon of Sin (MAP30) Main"}, + 361442: {'name': 'Icon of Sin (MAP30) - Chaingun', + 'episode': 3, + 'map': 10, + 'index': 41, + 'doom_type': 2002, + 'region': "Icon of Sin (MAP30) Main"}, + 361443: {'name': 'Icon of Sin (MAP30) - Chainsaw', + 'episode': 3, + 'map': 10, + 'index': 42, + 'doom_type': 2005, + 'region': "Icon of Sin (MAP30) Main"}, + 361444: {'name': 'Icon of Sin (MAP30) - Plasma gun', + 'episode': 3, + 'map': 10, + 'index': 43, + 'doom_type': 2004, + 'region': "Icon of Sin (MAP30) Main"}, + 361445: {'name': 'Icon of Sin (MAP30) - Rocket launcher', + 'episode': 3, + 'map': 10, + 'index': 44, + 'doom_type': 2003, + 'region': "Icon of Sin (MAP30) Main"}, + 361446: {'name': 'Icon of Sin (MAP30) - Shotgun', + 'episode': 3, + 'map': 10, + 'index': 45, + 'doom_type': 2001, + 'region': "Icon of Sin (MAP30) Main"}, + 361447: {'name': 'Icon of Sin (MAP30) - Super Shotgun', + 'episode': 3, + 'map': 10, + 'index': 46, + 'doom_type': 82, + 'region': "Icon of Sin (MAP30) Main"}, + 361448: {'name': 'Icon of Sin (MAP30) - Backpack', + 'episode': 3, + 'map': 10, + 'index': 47, + 'doom_type': 8, + 'region': "Icon of Sin (MAP30) Main"}, + 361449: {'name': 'Icon of Sin (MAP30) - Megasphere', + 'episode': 3, + 'map': 10, + 'index': 64, + 'doom_type': 83, + 'region': "Icon of Sin (MAP30) Main"}, + 361450: {'name': 'Icon of Sin (MAP30) - Megasphere 2', + 'episode': 3, + 'map': 10, + 'index': 85, + 'doom_type': 83, + 'region': "Icon of Sin (MAP30) Main"}, + 361451: {'name': 'Icon of Sin (MAP30) - Berserk', + 'episode': 3, + 'map': 10, + 'index': 94, + 'doom_type': 2023, + 'region': "Icon of Sin (MAP30) Main"}, + 361452: {'name': 'Icon of Sin (MAP30) - Exit', + 'episode': 3, + 'map': 10, + 'index': -1, + 'doom_type': -1, + 'region': "Icon of Sin (MAP30) Main"}, + 361453: {'name': 'Wolfenstein2 (MAP31) - Rocket launcher', + 'episode': 4, + 'map': 1, + 'index': 110, + 'doom_type': 2003, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361454: {'name': 'Wolfenstein2 (MAP31) - Shotgun', + 'episode': 4, + 'map': 1, + 'index': 139, + 'doom_type': 2001, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361455: {'name': 'Wolfenstein2 (MAP31) - Berserk', + 'episode': 4, + 'map': 1, + 'index': 263, + 'doom_type': 2023, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361456: {'name': 'Wolfenstein2 (MAP31) - Supercharge', + 'episode': 4, + 'map': 1, + 'index': 278, + 'doom_type': 2013, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361457: {'name': 'Wolfenstein2 (MAP31) - Chaingun', + 'episode': 4, + 'map': 1, + 'index': 305, + 'doom_type': 2002, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361458: {'name': 'Wolfenstein2 (MAP31) - Super Shotgun', + 'episode': 4, + 'map': 1, + 'index': 308, + 'doom_type': 82, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361459: {'name': 'Wolfenstein2 (MAP31) - Partial invisibility', + 'episode': 4, + 'map': 1, + 'index': 309, + 'doom_type': 2024, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361460: {'name': 'Wolfenstein2 (MAP31) - Megasphere', + 'episode': 4, + 'map': 1, + 'index': 310, + 'doom_type': 83, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361461: {'name': 'Wolfenstein2 (MAP31) - Backpack', + 'episode': 4, + 'map': 1, + 'index': 311, + 'doom_type': 8, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361462: {'name': 'Wolfenstein2 (MAP31) - Backpack 2', + 'episode': 4, + 'map': 1, + 'index': 312, + 'doom_type': 8, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361463: {'name': 'Wolfenstein2 (MAP31) - Backpack 3', + 'episode': 4, + 'map': 1, + 'index': 313, + 'doom_type': 8, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361464: {'name': 'Wolfenstein2 (MAP31) - Backpack 4', + 'episode': 4, + 'map': 1, + 'index': 314, + 'doom_type': 8, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361465: {'name': 'Wolfenstein2 (MAP31) - BFG9000', + 'episode': 4, + 'map': 1, + 'index': 315, + 'doom_type': 2006, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361466: {'name': 'Wolfenstein2 (MAP31) - Plasma gun', + 'episode': 4, + 'map': 1, + 'index': 316, + 'doom_type': 2004, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361467: {'name': 'Wolfenstein2 (MAP31) - Exit', + 'episode': 4, + 'map': 1, + 'index': -1, + 'doom_type': -1, + 'region': "Wolfenstein2 (MAP31) Main"}, + 361468: {'name': 'Grosse2 (MAP32) - Plasma gun', + 'episode': 4, + 'map': 2, + 'index': 33, + 'doom_type': 2004, + 'region': "Grosse2 (MAP32) Main"}, + 361469: {'name': 'Grosse2 (MAP32) - Rocket launcher', + 'episode': 4, + 'map': 2, + 'index': 57, + 'doom_type': 2003, + 'region': "Grosse2 (MAP32) Main"}, + 361470: {'name': 'Grosse2 (MAP32) - Invulnerability', + 'episode': 4, + 'map': 2, + 'index': 70, + 'doom_type': 2022, + 'region': "Grosse2 (MAP32) Main"}, + 361471: {'name': 'Grosse2 (MAP32) - Super Shotgun', + 'episode': 4, + 'map': 2, + 'index': 74, + 'doom_type': 82, + 'region': "Grosse2 (MAP32) Main"}, + 361472: {'name': 'Grosse2 (MAP32) - BFG9000', + 'episode': 4, + 'map': 2, + 'index': 75, + 'doom_type': 2006, + 'region': "Grosse2 (MAP32) Main"}, + 361473: {'name': 'Grosse2 (MAP32) - Megasphere', + 'episode': 4, + 'map': 2, + 'index': 78, + 'doom_type': 83, + 'region': "Grosse2 (MAP32) Main"}, + 361474: {'name': 'Grosse2 (MAP32) - Chaingun', + 'episode': 4, + 'map': 2, + 'index': 79, + 'doom_type': 2002, + 'region': "Grosse2 (MAP32) Main"}, + 361475: {'name': 'Grosse2 (MAP32) - Chaingun 2', + 'episode': 4, + 'map': 2, + 'index': 80, + 'doom_type': 2002, + 'region': "Grosse2 (MAP32) Main"}, + 361476: {'name': 'Grosse2 (MAP32) - Chaingun 3', + 'episode': 4, + 'map': 2, + 'index': 81, + 'doom_type': 2002, + 'region': "Grosse2 (MAP32) Main"}, + 361477: {'name': 'Grosse2 (MAP32) - Berserk', + 'episode': 4, + 'map': 2, + 'index': 82, + 'doom_type': 2023, + 'region': "Grosse2 (MAP32) Main"}, + 361478: {'name': 'Grosse2 (MAP32) - Exit', + 'episode': 4, + 'map': 2, + 'index': -1, + 'doom_type': -1, + 'region': "Grosse2 (MAP32) Main"}, +} + + +location_name_groups: Dict[str, Set[str]] = { + 'Barrels o Fun (MAP23)': { + 'Barrels o Fun (MAP23) - Armor', + 'Barrels o Fun (MAP23) - BFG9000', + 'Barrels o Fun (MAP23) - Backpack', + 'Barrels o Fun (MAP23) - Backpack 2', + 'Barrels o Fun (MAP23) - Berserk', + 'Barrels o Fun (MAP23) - Computer area map', + 'Barrels o Fun (MAP23) - Exit', + 'Barrels o Fun (MAP23) - Megasphere', + 'Barrels o Fun (MAP23) - Rocket launcher', + 'Barrels o Fun (MAP23) - Shotgun', + 'Barrels o Fun (MAP23) - Supercharge', + 'Barrels o Fun (MAP23) - Yellow skull key', + }, + 'Bloodfalls (MAP25)': { + 'Bloodfalls (MAP25) - Armor', + 'Bloodfalls (MAP25) - BFG9000', + 'Bloodfalls (MAP25) - BFG9000 2', + 'Bloodfalls (MAP25) - Blue skull key', + 'Bloodfalls (MAP25) - Chaingun', + 'Bloodfalls (MAP25) - Exit', + 'Bloodfalls (MAP25) - Mega Armor', + 'Bloodfalls (MAP25) - Megasphere', + 'Bloodfalls (MAP25) - Partial invisibility', + 'Bloodfalls (MAP25) - Plasma gun', + 'Bloodfalls (MAP25) - Rocket launcher', + 'Bloodfalls (MAP25) - Super Shotgun', + }, + 'Circle of Death (MAP11)': { + 'Circle of Death (MAP11) - Armor', + 'Circle of Death (MAP11) - BFG9000', + 'Circle of Death (MAP11) - Backpack', + 'Circle of Death (MAP11) - Blue keycard', + 'Circle of Death (MAP11) - Chaingun', + 'Circle of Death (MAP11) - Exit', + 'Circle of Death (MAP11) - Invulnerability', + 'Circle of Death (MAP11) - Mega Armor', + 'Circle of Death (MAP11) - Partial invisibility', + 'Circle of Death (MAP11) - Plasma gun', + 'Circle of Death (MAP11) - Red keycard', + 'Circle of Death (MAP11) - Rocket launcher', + 'Circle of Death (MAP11) - Shotgun', + 'Circle of Death (MAP11) - Supercharge', + 'Circle of Death (MAP11) - Supercharge 2', + }, + 'Dead Simple (MAP07)': { + 'Dead Simple (MAP07) - Backpack', + 'Dead Simple (MAP07) - Berserk', + 'Dead Simple (MAP07) - Chaingun', + 'Dead Simple (MAP07) - Exit', + 'Dead Simple (MAP07) - Megasphere', + 'Dead Simple (MAP07) - Partial invisibility', + 'Dead Simple (MAP07) - Partial invisibility 2', + 'Dead Simple (MAP07) - Partial invisibility 3', + 'Dead Simple (MAP07) - Partial invisibility 4', + 'Dead Simple (MAP07) - Plasma gun', + 'Dead Simple (MAP07) - Rocket launcher', + 'Dead Simple (MAP07) - Super Shotgun', + }, + 'Downtown (MAP13)': { + 'Downtown (MAP13) - BFG9000', + 'Downtown (MAP13) - Backpack', + 'Downtown (MAP13) - Berserk', + 'Downtown (MAP13) - Berserk 2', + 'Downtown (MAP13) - Berserk 3', + 'Downtown (MAP13) - Blue keycard', + 'Downtown (MAP13) - Chaingun', + 'Downtown (MAP13) - Chainsaw', + 'Downtown (MAP13) - Computer area map', + 'Downtown (MAP13) - Exit', + 'Downtown (MAP13) - Invulnerability', + 'Downtown (MAP13) - Invulnerability 2', + 'Downtown (MAP13) - Mega Armor', + 'Downtown (MAP13) - Partial invisibility', + 'Downtown (MAP13) - Partial invisibility 2', + 'Downtown (MAP13) - Partial invisibility 3', + 'Downtown (MAP13) - Plasma gun', + 'Downtown (MAP13) - Red keycard', + 'Downtown (MAP13) - Rocket launcher', + 'Downtown (MAP13) - Shotgun', + 'Downtown (MAP13) - Supercharge', + 'Downtown (MAP13) - Yellow keycard', + }, + 'Entryway (MAP01)': { + 'Entryway (MAP01) - Armor', + 'Entryway (MAP01) - Chainsaw', + 'Entryway (MAP01) - Exit', + 'Entryway (MAP01) - Rocket launcher', + 'Entryway (MAP01) - Shotgun', + }, + 'Gotcha! (MAP20)': { + 'Gotcha! (MAP20) - Armor', + 'Gotcha! (MAP20) - Armor 2', + 'Gotcha! (MAP20) - BFG9000', + 'Gotcha! (MAP20) - Berserk', + 'Gotcha! (MAP20) - Exit', + 'Gotcha! (MAP20) - Mega Armor', + 'Gotcha! (MAP20) - Mega Armor 2', + 'Gotcha! (MAP20) - Megasphere', + 'Gotcha! (MAP20) - Plasma gun', + 'Gotcha! (MAP20) - Rocket launcher', + 'Gotcha! (MAP20) - Super Shotgun', + 'Gotcha! (MAP20) - Supercharge', + 'Gotcha! (MAP20) - Supercharge 2', + 'Gotcha! (MAP20) - Supercharge 3', + 'Gotcha! (MAP20) - Supercharge 4', + }, + 'Grosse2 (MAP32)': { + 'Grosse2 (MAP32) - BFG9000', + 'Grosse2 (MAP32) - Berserk', + 'Grosse2 (MAP32) - Chaingun', + 'Grosse2 (MAP32) - Chaingun 2', + 'Grosse2 (MAP32) - Chaingun 3', + 'Grosse2 (MAP32) - Exit', + 'Grosse2 (MAP32) - Invulnerability', + 'Grosse2 (MAP32) - Megasphere', + 'Grosse2 (MAP32) - Plasma gun', + 'Grosse2 (MAP32) - Rocket launcher', + 'Grosse2 (MAP32) - Super Shotgun', + }, + 'Icon of Sin (MAP30)': { + 'Icon of Sin (MAP30) - BFG9000', + 'Icon of Sin (MAP30) - Backpack', + 'Icon of Sin (MAP30) - Berserk', + 'Icon of Sin (MAP30) - Chaingun', + 'Icon of Sin (MAP30) - Chainsaw', + 'Icon of Sin (MAP30) - Exit', + 'Icon of Sin (MAP30) - Invulnerability', + 'Icon of Sin (MAP30) - Invulnerability 2', + 'Icon of Sin (MAP30) - Invulnerability 3', + 'Icon of Sin (MAP30) - Invulnerability 4', + 'Icon of Sin (MAP30) - Megasphere', + 'Icon of Sin (MAP30) - Megasphere 2', + 'Icon of Sin (MAP30) - Plasma gun', + 'Icon of Sin (MAP30) - Rocket launcher', + 'Icon of Sin (MAP30) - Shotgun', + 'Icon of Sin (MAP30) - Super Shotgun', + 'Icon of Sin (MAP30) - Supercharge', + 'Icon of Sin (MAP30) - Supercharge 2', + 'Icon of Sin (MAP30) - Supercharge 3', + }, + 'Industrial Zone (MAP15)': { + 'Industrial Zone (MAP15) - Armor', + 'Industrial Zone (MAP15) - BFG9000', + 'Industrial Zone (MAP15) - Backpack', + 'Industrial Zone (MAP15) - Backpack 2', + 'Industrial Zone (MAP15) - Berserk', + 'Industrial Zone (MAP15) - Berserk 2', + 'Industrial Zone (MAP15) - Blue keycard', + 'Industrial Zone (MAP15) - Chaingun', + 'Industrial Zone (MAP15) - Chainsaw', + 'Industrial Zone (MAP15) - Computer area map', + 'Industrial Zone (MAP15) - Exit', + 'Industrial Zone (MAP15) - Invulnerability', + 'Industrial Zone (MAP15) - Mega Armor', + 'Industrial Zone (MAP15) - Mega Armor 2', + 'Industrial Zone (MAP15) - Megasphere', + 'Industrial Zone (MAP15) - Partial invisibility', + 'Industrial Zone (MAP15) - Partial invisibility 2', + 'Industrial Zone (MAP15) - Plasma gun', + 'Industrial Zone (MAP15) - Red keycard', + 'Industrial Zone (MAP15) - Rocket launcher', + 'Industrial Zone (MAP15) - Shotgun', + 'Industrial Zone (MAP15) - Supercharge', + 'Industrial Zone (MAP15) - Yellow keycard', + }, + 'Monster Condo (MAP27)': { + 'Monster Condo (MAP27) - Armor', + 'Monster Condo (MAP27) - Armor 2', + 'Monster Condo (MAP27) - BFG9000', + 'Monster Condo (MAP27) - Backpack', + 'Monster Condo (MAP27) - Backpack 2', + 'Monster Condo (MAP27) - Backpack 3', + 'Monster Condo (MAP27) - Backpack 4', + 'Monster Condo (MAP27) - Berserk', + 'Monster Condo (MAP27) - Berserk 2', + 'Monster Condo (MAP27) - Blue skull key', + 'Monster Condo (MAP27) - Chaingun', + 'Monster Condo (MAP27) - Chainsaw', + 'Monster Condo (MAP27) - Computer area map', + 'Monster Condo (MAP27) - Computer area map 2', + 'Monster Condo (MAP27) - Exit', + 'Monster Condo (MAP27) - Invulnerability', + 'Monster Condo (MAP27) - Invulnerability 2', + 'Monster Condo (MAP27) - Invulnerability 3', + 'Monster Condo (MAP27) - Partial invisibility', + 'Monster Condo (MAP27) - Partial invisibility 2', + 'Monster Condo (MAP27) - Partial invisibility 3', + 'Monster Condo (MAP27) - Plasma gun', + 'Monster Condo (MAP27) - Red skull key', + 'Monster Condo (MAP27) - Rocket launcher', + 'Monster Condo (MAP27) - Super Shotgun', + 'Monster Condo (MAP27) - Supercharge', + 'Monster Condo (MAP27) - Supercharge 2', + 'Monster Condo (MAP27) - Supercharge 3', + 'Monster Condo (MAP27) - Supercharge 4', + 'Monster Condo (MAP27) - Supercharge 5', + 'Monster Condo (MAP27) - Yellow skull key', + }, + 'Nirvana (MAP21)': { + 'Nirvana (MAP21) - Backpack', + 'Nirvana (MAP21) - Blue skull key', + 'Nirvana (MAP21) - Exit', + 'Nirvana (MAP21) - Invulnerability', + 'Nirvana (MAP21) - Megasphere', + 'Nirvana (MAP21) - Red skull key', + 'Nirvana (MAP21) - Rocket launcher', + 'Nirvana (MAP21) - Super Shotgun', + 'Nirvana (MAP21) - Yellow skull key', + }, + 'Refueling Base (MAP10)': { + 'Refueling Base (MAP10) - Armor', + 'Refueling Base (MAP10) - Armor 2', + 'Refueling Base (MAP10) - BFG9000', + 'Refueling Base (MAP10) - Backpack', + 'Refueling Base (MAP10) - Berserk', + 'Refueling Base (MAP10) - Berserk 2', + 'Refueling Base (MAP10) - Blue keycard', + 'Refueling Base (MAP10) - Chaingun', + 'Refueling Base (MAP10) - Chainsaw', + 'Refueling Base (MAP10) - Exit', + 'Refueling Base (MAP10) - Invulnerability', + 'Refueling Base (MAP10) - Invulnerability 2', + 'Refueling Base (MAP10) - Mega Armor', + 'Refueling Base (MAP10) - Megasphere', + 'Refueling Base (MAP10) - Partial invisibility', + 'Refueling Base (MAP10) - Plasma gun', + 'Refueling Base (MAP10) - Rocket launcher', + 'Refueling Base (MAP10) - Shotgun', + 'Refueling Base (MAP10) - Supercharge', + 'Refueling Base (MAP10) - Supercharge 2', + 'Refueling Base (MAP10) - Yellow keycard', + }, + 'Suburbs (MAP16)': { + 'Suburbs (MAP16) - BFG9000', + 'Suburbs (MAP16) - Backpack', + 'Suburbs (MAP16) - Berserk', + 'Suburbs (MAP16) - Blue skull key', + 'Suburbs (MAP16) - Chaingun', + 'Suburbs (MAP16) - Exit', + 'Suburbs (MAP16) - Invulnerability', + 'Suburbs (MAP16) - Megasphere', + 'Suburbs (MAP16) - Partial invisibility', + 'Suburbs (MAP16) - Plasma gun', + 'Suburbs (MAP16) - Plasma gun 2', + 'Suburbs (MAP16) - Plasma gun 3', + 'Suburbs (MAP16) - Plasma gun 4', + 'Suburbs (MAP16) - Red skull key', + 'Suburbs (MAP16) - Rocket launcher', + 'Suburbs (MAP16) - Shotgun', + 'Suburbs (MAP16) - Super Shotgun', + 'Suburbs (MAP16) - Supercharge', + }, + 'Tenements (MAP17)': { + 'Tenements (MAP17) - Armor', + 'Tenements (MAP17) - Armor 2', + 'Tenements (MAP17) - BFG9000', + 'Tenements (MAP17) - Backpack', + 'Tenements (MAP17) - Berserk', + 'Tenements (MAP17) - Blue keycard', + 'Tenements (MAP17) - Chaingun', + 'Tenements (MAP17) - Exit', + 'Tenements (MAP17) - Mega Armor', + 'Tenements (MAP17) - Megasphere', + 'Tenements (MAP17) - Partial invisibility', + 'Tenements (MAP17) - Plasma gun', + 'Tenements (MAP17) - Red keycard', + 'Tenements (MAP17) - Rocket launcher', + 'Tenements (MAP17) - Shotgun', + 'Tenements (MAP17) - Supercharge', + 'Tenements (MAP17) - Supercharge 2', + 'Tenements (MAP17) - Yellow skull key', + }, + 'The Abandoned Mines (MAP26)': { + 'The Abandoned Mines (MAP26) - Armor', + 'The Abandoned Mines (MAP26) - Backpack', + 'The Abandoned Mines (MAP26) - Blue keycard', + 'The Abandoned Mines (MAP26) - Chaingun', + 'The Abandoned Mines (MAP26) - Exit', + 'The Abandoned Mines (MAP26) - Mega Armor', + 'The Abandoned Mines (MAP26) - Partial invisibility', + 'The Abandoned Mines (MAP26) - Plasma gun', + 'The Abandoned Mines (MAP26) - Red keycard', + 'The Abandoned Mines (MAP26) - Rocket launcher', + 'The Abandoned Mines (MAP26) - Super Shotgun', + 'The Abandoned Mines (MAP26) - Supercharge', + 'The Abandoned Mines (MAP26) - Yellow keycard', + }, + 'The Catacombs (MAP22)': { + 'The Catacombs (MAP22) - Armor', + 'The Catacombs (MAP22) - Berserk', + 'The Catacombs (MAP22) - Blue skull key', + 'The Catacombs (MAP22) - Exit', + 'The Catacombs (MAP22) - Plasma gun', + 'The Catacombs (MAP22) - Red skull key', + 'The Catacombs (MAP22) - Rocket launcher', + 'The Catacombs (MAP22) - Shotgun', + 'The Catacombs (MAP22) - Supercharge', + }, + 'The Chasm (MAP24)': { + 'The Chasm (MAP24) - Armor', + 'The Chasm (MAP24) - BFG9000', + 'The Chasm (MAP24) - Backpack', + 'The Chasm (MAP24) - Berserk', + 'The Chasm (MAP24) - Berserk 2', + 'The Chasm (MAP24) - Blue keycard', + 'The Chasm (MAP24) - Exit', + 'The Chasm (MAP24) - Invulnerability', + 'The Chasm (MAP24) - Megasphere', + 'The Chasm (MAP24) - Megasphere 2', + 'The Chasm (MAP24) - Plasma gun', + 'The Chasm (MAP24) - Red keycard', + 'The Chasm (MAP24) - Rocket launcher', + 'The Chasm (MAP24) - Shotgun', + 'The Chasm (MAP24) - Super Shotgun', + }, + 'The Citadel (MAP19)': { + 'The Citadel (MAP19) - Armor', + 'The Citadel (MAP19) - Armor 2', + 'The Citadel (MAP19) - Backpack', + 'The Citadel (MAP19) - Berserk', + 'The Citadel (MAP19) - Blue skull key', + 'The Citadel (MAP19) - Chaingun', + 'The Citadel (MAP19) - Computer area map', + 'The Citadel (MAP19) - Exit', + 'The Citadel (MAP19) - Invulnerability', + 'The Citadel (MAP19) - Mega Armor', + 'The Citadel (MAP19) - Partial invisibility', + 'The Citadel (MAP19) - Red skull key', + 'The Citadel (MAP19) - Rocket launcher', + 'The Citadel (MAP19) - Super Shotgun', + 'The Citadel (MAP19) - Supercharge', + 'The Citadel (MAP19) - Yellow skull key', + }, + 'The Courtyard (MAP18)': { + 'The Courtyard (MAP18) - Armor', + 'The Courtyard (MAP18) - BFG9000', + 'The Courtyard (MAP18) - Backpack', + 'The Courtyard (MAP18) - Berserk', + 'The Courtyard (MAP18) - Blue skull key', + 'The Courtyard (MAP18) - Chaingun', + 'The Courtyard (MAP18) - Computer area map', + 'The Courtyard (MAP18) - Exit', + 'The Courtyard (MAP18) - Invulnerability', + 'The Courtyard (MAP18) - Invulnerability 2', + 'The Courtyard (MAP18) - Partial invisibility', + 'The Courtyard (MAP18) - Partial invisibility 2', + 'The Courtyard (MAP18) - Plasma gun', + 'The Courtyard (MAP18) - Rocket launcher', + 'The Courtyard (MAP18) - Shotgun', + 'The Courtyard (MAP18) - Super Shotgun', + 'The Courtyard (MAP18) - Supercharge', + 'The Courtyard (MAP18) - Yellow skull key', + }, + 'The Crusher (MAP06)': { + 'The Crusher (MAP06) - Armor', + 'The Crusher (MAP06) - Backpack', + 'The Crusher (MAP06) - Blue keycard', + 'The Crusher (MAP06) - Blue keycard 2', + 'The Crusher (MAP06) - Blue keycard 3', + 'The Crusher (MAP06) - Exit', + 'The Crusher (MAP06) - Mega Armor', + 'The Crusher (MAP06) - Megasphere', + 'The Crusher (MAP06) - Megasphere 2', + 'The Crusher (MAP06) - Plasma gun', + 'The Crusher (MAP06) - Red keycard', + 'The Crusher (MAP06) - Rocket launcher', + 'The Crusher (MAP06) - Super Shotgun', + 'The Crusher (MAP06) - Supercharge', + 'The Crusher (MAP06) - Yellow keycard', + }, + 'The Factory (MAP12)': { + 'The Factory (MAP12) - Armor', + 'The Factory (MAP12) - Armor 2', + 'The Factory (MAP12) - BFG9000', + 'The Factory (MAP12) - Backpack', + 'The Factory (MAP12) - Berserk', + 'The Factory (MAP12) - Berserk 2', + 'The Factory (MAP12) - Berserk 3', + 'The Factory (MAP12) - Blue keycard', + 'The Factory (MAP12) - Chaingun', + 'The Factory (MAP12) - Exit', + 'The Factory (MAP12) - Partial invisibility', + 'The Factory (MAP12) - Shotgun', + 'The Factory (MAP12) - Super Shotgun', + 'The Factory (MAP12) - Supercharge', + 'The Factory (MAP12) - Supercharge 2', + 'The Factory (MAP12) - Yellow keycard', + }, + 'The Focus (MAP04)': { + 'The Focus (MAP04) - Blue keycard', + 'The Focus (MAP04) - Exit', + 'The Focus (MAP04) - Red keycard', + 'The Focus (MAP04) - Super Shotgun', + 'The Focus (MAP04) - Yellow keycard', + }, + 'The Gantlet (MAP03)': { + 'The Gantlet (MAP03) - Backpack', + 'The Gantlet (MAP03) - Blue keycard', + 'The Gantlet (MAP03) - Chaingun', + 'The Gantlet (MAP03) - Exit', + 'The Gantlet (MAP03) - Mega Armor', + 'The Gantlet (MAP03) - Mega Armor 2', + 'The Gantlet (MAP03) - Partial invisibility', + 'The Gantlet (MAP03) - Red keycard', + 'The Gantlet (MAP03) - Rocket launcher', + 'The Gantlet (MAP03) - Shotgun', + 'The Gantlet (MAP03) - Supercharge', + }, + 'The Inmost Dens (MAP14)': { + 'The Inmost Dens (MAP14) - Berserk', + 'The Inmost Dens (MAP14) - Blue skull key', + 'The Inmost Dens (MAP14) - Chaingun', + 'The Inmost Dens (MAP14) - Exit', + 'The Inmost Dens (MAP14) - Mega Armor', + 'The Inmost Dens (MAP14) - Partial invisibility', + 'The Inmost Dens (MAP14) - Plasma gun', + 'The Inmost Dens (MAP14) - Red skull key', + 'The Inmost Dens (MAP14) - Rocket launcher', + 'The Inmost Dens (MAP14) - Shotgun', + 'The Inmost Dens (MAP14) - Supercharge', + }, + 'The Living End (MAP29)': { + 'The Living End (MAP29) - Armor', + 'The Living End (MAP29) - Backpack', + 'The Living End (MAP29) - Backpack 2', + 'The Living End (MAP29) - Berserk', + 'The Living End (MAP29) - Berserk 2', + 'The Living End (MAP29) - Chaingun', + 'The Living End (MAP29) - Exit', + 'The Living End (MAP29) - Mega Armor', + 'The Living End (MAP29) - Plasma gun', + 'The Living End (MAP29) - Super Shotgun', + 'The Living End (MAP29) - Supercharge', + }, + 'The Pit (MAP09)': { + 'The Pit (MAP09) - Armor', + 'The Pit (MAP09) - BFG9000', + 'The Pit (MAP09) - Backpack', + 'The Pit (MAP09) - Berserk', + 'The Pit (MAP09) - Berserk 2', + 'The Pit (MAP09) - Berserk 3', + 'The Pit (MAP09) - Blue keycard', + 'The Pit (MAP09) - Computer area map', + 'The Pit (MAP09) - Exit', + 'The Pit (MAP09) - Mega Armor', + 'The Pit (MAP09) - Mega Armor 2', + 'The Pit (MAP09) - Rocket launcher', + 'The Pit (MAP09) - Shotgun', + 'The Pit (MAP09) - Supercharge', + 'The Pit (MAP09) - Supercharge 2', + 'The Pit (MAP09) - Yellow keycard', + }, + 'The Spirit World (MAP28)': { + 'The Spirit World (MAP28) - Armor', + 'The Spirit World (MAP28) - BFG9000', + 'The Spirit World (MAP28) - Backpack', + 'The Spirit World (MAP28) - Backpack 2', + 'The Spirit World (MAP28) - Backpack 3', + 'The Spirit World (MAP28) - Backpack 4', + 'The Spirit World (MAP28) - Berserk', + 'The Spirit World (MAP28) - Chaingun', + 'The Spirit World (MAP28) - Chainsaw', + 'The Spirit World (MAP28) - Exit', + 'The Spirit World (MAP28) - Invulnerability', + 'The Spirit World (MAP28) - Invulnerability 2', + 'The Spirit World (MAP28) - Invulnerability 3', + 'The Spirit World (MAP28) - Invulnerability 4', + 'The Spirit World (MAP28) - Invulnerability 5', + 'The Spirit World (MAP28) - Megasphere', + 'The Spirit World (MAP28) - Megasphere 2', + 'The Spirit World (MAP28) - Plasma gun', + 'The Spirit World (MAP28) - Red skull key', + 'The Spirit World (MAP28) - Rocket launcher', + 'The Spirit World (MAP28) - Super Shotgun', + 'The Spirit World (MAP28) - Supercharge', + 'The Spirit World (MAP28) - Yellow skull key', + }, + 'The Waste Tunnels (MAP05)': { + 'The Waste Tunnels (MAP05) - Armor', + 'The Waste Tunnels (MAP05) - Berserk', + 'The Waste Tunnels (MAP05) - Blue keycard', + 'The Waste Tunnels (MAP05) - Exit', + 'The Waste Tunnels (MAP05) - Mega Armor', + 'The Waste Tunnels (MAP05) - Plasma gun', + 'The Waste Tunnels (MAP05) - Red keycard', + 'The Waste Tunnels (MAP05) - Rocket launcher', + 'The Waste Tunnels (MAP05) - Shotgun', + 'The Waste Tunnels (MAP05) - Super Shotgun', + 'The Waste Tunnels (MAP05) - Supercharge', + 'The Waste Tunnels (MAP05) - Supercharge 2', + 'The Waste Tunnels (MAP05) - Yellow keycard', + }, + 'Tricks and Traps (MAP08)': { + 'Tricks and Traps (MAP08) - Armor', + 'Tricks and Traps (MAP08) - Armor 2', + 'Tricks and Traps (MAP08) - BFG9000', + 'Tricks and Traps (MAP08) - Backpack', + 'Tricks and Traps (MAP08) - Backpack 2', + 'Tricks and Traps (MAP08) - Backpack 3', + 'Tricks and Traps (MAP08) - Backpack 4', + 'Tricks and Traps (MAP08) - Backpack 5', + 'Tricks and Traps (MAP08) - Chaingun', + 'Tricks and Traps (MAP08) - Chainsaw', + 'Tricks and Traps (MAP08) - Exit', + 'Tricks and Traps (MAP08) - Invulnerability', + 'Tricks and Traps (MAP08) - Invulnerability 2', + 'Tricks and Traps (MAP08) - Invulnerability 3', + 'Tricks and Traps (MAP08) - Invulnerability 4', + 'Tricks and Traps (MAP08) - Invulnerability 5', + 'Tricks and Traps (MAP08) - Partial invisibility', + 'Tricks and Traps (MAP08) - Plasma gun', + 'Tricks and Traps (MAP08) - Red skull key', + 'Tricks and Traps (MAP08) - Rocket launcher', + 'Tricks and Traps (MAP08) - Shotgun', + 'Tricks and Traps (MAP08) - Supercharge', + 'Tricks and Traps (MAP08) - Supercharge 2', + 'Tricks and Traps (MAP08) - Yellow skull key', + }, + 'Underhalls (MAP02)': { + 'Underhalls (MAP02) - Blue keycard', + 'Underhalls (MAP02) - Exit', + 'Underhalls (MAP02) - Mega Armor', + 'Underhalls (MAP02) - Red keycard', + 'Underhalls (MAP02) - Super Shotgun', + }, + 'Wolfenstein2 (MAP31)': { + 'Wolfenstein2 (MAP31) - BFG9000', + 'Wolfenstein2 (MAP31) - Backpack', + 'Wolfenstein2 (MAP31) - Backpack 2', + 'Wolfenstein2 (MAP31) - Backpack 3', + 'Wolfenstein2 (MAP31) - Backpack 4', + 'Wolfenstein2 (MAP31) - Berserk', + 'Wolfenstein2 (MAP31) - Chaingun', + 'Wolfenstein2 (MAP31) - Exit', + 'Wolfenstein2 (MAP31) - Megasphere', + 'Wolfenstein2 (MAP31) - Partial invisibility', + 'Wolfenstein2 (MAP31) - Plasma gun', + 'Wolfenstein2 (MAP31) - Rocket launcher', + 'Wolfenstein2 (MAP31) - Shotgun', + 'Wolfenstein2 (MAP31) - Super Shotgun', + 'Wolfenstein2 (MAP31) - Supercharge', + }, +} + + +death_logic_locations = [ + "Entryway (MAP01) - Armor", +] diff --git a/worlds/doom_ii/Maps.py b/worlds/doom_ii/Maps.py new file mode 100644 index 0000000000..cf41939fa5 --- /dev/null +++ b/worlds/doom_ii/Maps.py @@ -0,0 +1,39 @@ +# This file is auto generated. More info: https://github.com/Daivuk/apdoom + +from typing import List + + +map_names: List[str] = [ + 'Entryway (MAP01)', + 'Underhalls (MAP02)', + 'The Gantlet (MAP03)', + 'The Focus (MAP04)', + 'The Waste Tunnels (MAP05)', + 'The Crusher (MAP06)', + 'Dead Simple (MAP07)', + 'Tricks and Traps (MAP08)', + 'The Pit (MAP09)', + 'Refueling Base (MAP10)', + 'Circle of Death (MAP11)', + 'The Factory (MAP12)', + 'Downtown (MAP13)', + 'The Inmost Dens (MAP14)', + 'Industrial Zone (MAP15)', + 'Suburbs (MAP16)', + 'Tenements (MAP17)', + 'The Courtyard (MAP18)', + 'The Citadel (MAP19)', + 'Gotcha! (MAP20)', + 'Nirvana (MAP21)', + 'The Catacombs (MAP22)', + 'Barrels o Fun (MAP23)', + 'The Chasm (MAP24)', + 'Bloodfalls (MAP25)', + 'The Abandoned Mines (MAP26)', + 'Monster Condo (MAP27)', + 'The Spirit World (MAP28)', + 'The Living End (MAP29)', + 'Icon of Sin (MAP30)', + 'Wolfenstein2 (MAP31)', + 'Grosse2 (MAP32)', +] diff --git a/worlds/doom_ii/Options.py b/worlds/doom_ii/Options.py new file mode 100644 index 0000000000..cc39512a17 --- /dev/null +++ b/worlds/doom_ii/Options.py @@ -0,0 +1,150 @@ +import typing + +from Options import PerGameCommonOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool +from dataclasses import dataclass + + +class Difficulty(Choice): + """ + Choose the difficulty option. Those match DOOM's difficulty options. + baby (I'm too young to die.) double ammos, half damage, less monsters or strength. + easy (Hey, not too rough.) less monsters or strength. + medium (Hurt me plenty.) Default. + hard (Ultra-Violence.) More monsters or strength. + nightmare (Nightmare!) Monsters attack more rapidly and respawn. + """ + display_name = "Difficulty" + option_baby = 0 + option_easy = 1 + option_medium = 2 + option_hard = 3 + option_nightmare = 4 + default = 2 + + +class RandomMonsters(Choice): + """ + Choose how monsters are randomized. + vanilla: No randomization + shuffle: Monsters are shuffled within the level + random_balanced: Monsters are completely randomized, but balanced based on existing ratio in the level. (Small monsters vs medium vs big) + random_chaotic: Monsters are completely randomized, but balanced based on existing ratio in the entire game. + """ + display_name = "Random Monsters" + option_vanilla = 0 + option_shuffle = 1 + option_random_balanced = 2 + option_random_chaotic = 3 + default = 2 + + +class RandomPickups(Choice): + """ + Choose how pickups are randomized. + vanilla: No randomization + shuffle: Pickups are shuffled within the level + random_balanced: Pickups are completely randomized, but balanced based on existing ratio in the level. (Small pickups vs Big) + """ + display_name = "Random Pickups" + option_vanilla = 0 + option_shuffle = 1 + option_random_balanced = 2 + default = 1 + + +class RandomMusic(Choice): + """ + Level musics will be randomized. + vanilla: No randomization + shuffle_selected: Selected episodes' levels will be shuffled + shuffle_game: All the music will be shuffled + """ + display_name = "Random Music" + option_vanilla = 0 + option_shuffle_selected = 1 + option_shuffle_game = 2 + default = 0 + + +class FlipLevels(Choice): + """ + Flip levels on one axis. + vanilla: No flipping + flipped: All levels are flipped + random: Random levels are flipped + """ + display_name = "Flip Levels" + option_vanilla = 0 + option_flipped = 1 + option_randomly_flipped = 2 + default = 0 + + +class AllowDeathLogic(Toggle): + """Some locations require a timed puzzle that can only be tried once. + After which, if the player failed to get it, the location cannot be checked anymore. + By default, no progression items are placed here. There is a way, hovewer, to still get them: + Get killed in the current map. The map will reset, you can now attempt the puzzle again.""" + display_name = "Allow Death Logic" + + +class Pro(Toggle): + """Include difficult tricks into rules. Mostly employed by speed runners. + i.e.: Leaps across to a locked area, trigger a switch behind a window at the right angle, etc.""" + display_name = "Pro Doom" + + +class StartWithComputerAreaMaps(Toggle): + """Give the player all Computer Area Map items from the start.""" + display_name = "Start With Computer Area Maps" + + +class ResetLevelOnDeath(DefaultOnToggle): + """When dying, levels are reset and monsters respawned. But inventory and checks are kept. + Turning this setting off is considered easy mode. Good for new players that don't know the levels well.""" + display_message="Reset level on death" + + +class Episode1(DefaultOnToggle): + """Subterranean and Outpost. + If none of the episodes are chosen, Episode 1 will be chosen by default.""" + display_name = "Episode 1" + + +class Episode2(DefaultOnToggle): + """City. + If none of the episodes are chosen, Episode 1 will be chosen by default.""" + display_name = "Episode 2" + + +class Episode3(DefaultOnToggle): + """Hell. + If none of the episodes are chosen, Episode 1 will be chosen by default.""" + display_name = "Episode 3" + + +class SecretLevels(Toggle): + """Secret levels. + This is too short to be an episode. It's additive. + Another episode will have to be selected along with this one. + Otherwise episode 1 will be added.""" + display_name = "Secret Levels" + + +@dataclass +class DOOM2Options(PerGameCommonOptions): + start_inventory_from_pool: StartInventoryPool + difficulty: Difficulty + random_monsters: RandomMonsters + random_pickups: RandomPickups + random_music: RandomMusic + flip_levels: FlipLevels + allow_death_logic: AllowDeathLogic + pro: Pro + start_with_computer_area_maps: StartWithComputerAreaMaps + death_link: DeathLink + reset_level_on_death: ResetLevelOnDeath + episode1: Episode1 + episode2: Episode2 + episode3: Episode3 + episode4: SecretLevels diff --git a/worlds/doom_ii/Regions.py b/worlds/doom_ii/Regions.py new file mode 100644 index 0000000000..3d81d7abb8 --- /dev/null +++ b/worlds/doom_ii/Regions.py @@ -0,0 +1,502 @@ +# This file is auto generated. More info: https://github.com/Daivuk/apdoom + +from typing import List +from BaseClasses import TypedDict + +class ConnectionDict(TypedDict, total=False): + target: str + pro: bool + +class RegionDict(TypedDict, total=False): + name: str + connects_to_hub: bool + episode: int + connections: List[ConnectionDict] + + +regions:List[RegionDict] = [ + # Entryway (MAP01) + {"name":"Entryway (MAP01) Main", + "connects_to_hub":True, + "episode":1, + "connections":[]}, + + # Underhalls (MAP02) + {"name":"Underhalls (MAP02) Main", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"Underhalls (MAP02) Red","pro":False}]}, + {"name":"Underhalls (MAP02) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Underhalls (MAP02) Red","pro":False}]}, + {"name":"Underhalls (MAP02) Red", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"Underhalls (MAP02) Blue","pro":False}, + {"target":"Underhalls (MAP02) Main","pro":False}]}, + + # The Gantlet (MAP03) + {"name":"The Gantlet (MAP03) Main", + "connects_to_hub":True, + "episode":1, + "connections":[ + {"target":"The Gantlet (MAP03) Blue","pro":False}, + {"target":"The Gantlet (MAP03) Blue Pro Jump","pro":True}]}, + {"name":"The Gantlet (MAP03) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"The Gantlet (MAP03) Main","pro":False}, + {"target":"The Gantlet (MAP03) Red","pro":False}, + {"target":"The Gantlet (MAP03) Blue Pro Jump","pro":False}]}, + {"name":"The Gantlet (MAP03) Red", + "connects_to_hub":False, + "episode":1, + "connections":[]}, + {"name":"The Gantlet (MAP03) Blue Pro Jump", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Gantlet (MAP03) Blue","pro":False}]}, + + # The Focus (MAP04) + {"name":"The Focus (MAP04) Main", + "connects_to_hub":True, + "episode":1, + "connections":[ + {"target":"The Focus (MAP04) Red","pro":False}, + {"target":"The Focus (MAP04) Blue","pro":False}]}, + {"name":"The Focus (MAP04) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Focus (MAP04) Main","pro":False}]}, + {"name":"The Focus (MAP04) Yellow", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Focus (MAP04) Red","pro":False}]}, + {"name":"The Focus (MAP04) Red", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"The Focus (MAP04) Yellow","pro":False}, + {"target":"The Focus (MAP04) Main","pro":False}]}, + + # The Waste Tunnels (MAP05) + {"name":"The Waste Tunnels (MAP05) Main", + "connects_to_hub":True, + "episode":1, + "connections":[ + {"target":"The Waste Tunnels (MAP05) Red","pro":False}, + {"target":"The Waste Tunnels (MAP05) Blue","pro":False}]}, + {"name":"The Waste Tunnels (MAP05) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"The Waste Tunnels (MAP05) Yellow","pro":False}, + {"target":"The Waste Tunnels (MAP05) Main","pro":False}]}, + {"name":"The Waste Tunnels (MAP05) Yellow", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Waste Tunnels (MAP05) Blue","pro":False}]}, + {"name":"The Waste Tunnels (MAP05) Red", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Waste Tunnels (MAP05) Main","pro":False}]}, + + # The Crusher (MAP06) + {"name":"The Crusher (MAP06) Main", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"The Crusher (MAP06) Blue","pro":False}]}, + {"name":"The Crusher (MAP06) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"The Crusher (MAP06) Red","pro":False}, + {"target":"The Crusher (MAP06) Main","pro":False}]}, + {"name":"The Crusher (MAP06) Yellow", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Crusher (MAP06) Red","pro":False}]}, + {"name":"The Crusher (MAP06) Red", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"The Crusher (MAP06) Yellow","pro":False}, + {"target":"The Crusher (MAP06) Blue","pro":False}, + {"target":"The Crusher (MAP06) Main","pro":False}]}, + + # Dead Simple (MAP07) + {"name":"Dead Simple (MAP07) Main", + "connects_to_hub":True, + "episode":1, + "connections":[]}, + + # Tricks and Traps (MAP08) + {"name":"Tricks and Traps (MAP08) Main", + "connects_to_hub":True, + "episode":1, + "connections":[ + {"target":"Tricks and Traps (MAP08) Red","pro":False}, + {"target":"Tricks and Traps (MAP08) Yellow","pro":False}]}, + {"name":"Tricks and Traps (MAP08) Yellow", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Tricks and Traps (MAP08) Main","pro":False}]}, + {"name":"Tricks and Traps (MAP08) Red", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Tricks and Traps (MAP08) Main","pro":False}]}, + + # The Pit (MAP09) + {"name":"The Pit (MAP09) Main", + "connects_to_hub":True, + "episode":1, + "connections":[ + {"target":"The Pit (MAP09) Yellow","pro":False}, + {"target":"The Pit (MAP09) Blue","pro":False}]}, + {"name":"The Pit (MAP09) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[]}, + {"name":"The Pit (MAP09) Yellow", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"The Pit (MAP09) Main","pro":False}]}, + + # Refueling Base (MAP10) + {"name":"Refueling Base (MAP10) Main", + "connects_to_hub":True, + "episode":1, + "connections":[{"target":"Refueling Base (MAP10) Yellow","pro":False}]}, + {"name":"Refueling Base (MAP10) Yellow", + "connects_to_hub":False, + "episode":1, + "connections":[ + {"target":"Refueling Base (MAP10) Main","pro":False}, + {"target":"Refueling Base (MAP10) Yellow Blue","pro":False}]}, + {"name":"Refueling Base (MAP10) Yellow Blue", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Refueling Base (MAP10) Yellow","pro":False}]}, + + # Circle of Death (MAP11) + {"name":"Circle of Death (MAP11) Main", + "connects_to_hub":True, + "episode":1, + "connections":[ + {"target":"Circle of Death (MAP11) Blue","pro":False}, + {"target":"Circle of Death (MAP11) Red","pro":False}]}, + {"name":"Circle of Death (MAP11) Blue", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Circle of Death (MAP11) Main","pro":False}]}, + {"name":"Circle of Death (MAP11) Red", + "connects_to_hub":False, + "episode":1, + "connections":[{"target":"Circle of Death (MAP11) Main","pro":False}]}, + + # The Factory (MAP12) + {"name":"The Factory (MAP12) Main", + "connects_to_hub":True, + "episode":2, + "connections":[ + {"target":"The Factory (MAP12) Yellow","pro":False}, + {"target":"The Factory (MAP12) Blue","pro":False}]}, + {"name":"The Factory (MAP12) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"The Factory (MAP12) Main","pro":False}]}, + {"name":"The Factory (MAP12) Yellow", + "connects_to_hub":False, + "episode":2, + "connections":[]}, + + # Downtown (MAP13) + {"name":"Downtown (MAP13) Main", + "connects_to_hub":True, + "episode":2, + "connections":[ + {"target":"Downtown (MAP13) Yellow","pro":False}, + {"target":"Downtown (MAP13) Red","pro":False}, + {"target":"Downtown (MAP13) Blue","pro":False}]}, + {"name":"Downtown (MAP13) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Downtown (MAP13) Main","pro":False}]}, + {"name":"Downtown (MAP13) Yellow", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Downtown (MAP13) Main","pro":False}]}, + {"name":"Downtown (MAP13) Red", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Downtown (MAP13) Main","pro":False}]}, + + # The Inmost Dens (MAP14) + {"name":"The Inmost Dens (MAP14) Main", + "connects_to_hub":True, + "episode":2, + "connections":[{"target":"The Inmost Dens (MAP14) Red","pro":False}]}, + {"name":"The Inmost Dens (MAP14) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"The Inmost Dens (MAP14) Main","pro":False}, + {"target":"The Inmost Dens (MAP14) Red East","pro":False}]}, + {"name":"The Inmost Dens (MAP14) Red", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"The Inmost Dens (MAP14) Main","pro":False}, + {"target":"The Inmost Dens (MAP14) Red South","pro":False}, + {"target":"The Inmost Dens (MAP14) Red East","pro":False}]}, + {"name":"The Inmost Dens (MAP14) Red East", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"The Inmost Dens (MAP14) Blue","pro":False}, + {"target":"The Inmost Dens (MAP14) Main","pro":False}]}, + {"name":"The Inmost Dens (MAP14) Red South", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"The Inmost Dens (MAP14) Main","pro":False}]}, + + # Industrial Zone (MAP15) + {"name":"Industrial Zone (MAP15) Main", + "connects_to_hub":True, + "episode":2, + "connections":[ + {"target":"Industrial Zone (MAP15) Yellow East","pro":False}, + {"target":"Industrial Zone (MAP15) Yellow West","pro":False}]}, + {"name":"Industrial Zone (MAP15) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Industrial Zone (MAP15) Yellow East","pro":False}]}, + {"name":"Industrial Zone (MAP15) Yellow East", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"Industrial Zone (MAP15) Blue","pro":False}, + {"target":"Industrial Zone (MAP15) Main","pro":False}]}, + {"name":"Industrial Zone (MAP15) Yellow West", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Industrial Zone (MAP15) Main","pro":False}]}, + + # Suburbs (MAP16) + {"name":"Suburbs (MAP16) Main", + "connects_to_hub":True, + "episode":2, + "connections":[ + {"target":"Suburbs (MAP16) Red","pro":False}, + {"target":"Suburbs (MAP16) Blue","pro":False}]}, + {"name":"Suburbs (MAP16) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Suburbs (MAP16) Main","pro":False}]}, + {"name":"Suburbs (MAP16) Red", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Suburbs (MAP16) Main","pro":False}]}, + + # Tenements (MAP17) + {"name":"Tenements (MAP17) Main", + "connects_to_hub":True, + "episode":2, + "connections":[{"target":"Tenements (MAP17) Red","pro":False}]}, + {"name":"Tenements (MAP17) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"Tenements (MAP17) Red","pro":False}]}, + {"name":"Tenements (MAP17) Yellow", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"Tenements (MAP17) Red","pro":False}, + {"target":"Tenements (MAP17) Blue","pro":False}]}, + {"name":"Tenements (MAP17) Red", + "connects_to_hub":False, + "episode":2, + "connections":[ + {"target":"Tenements (MAP17) Yellow","pro":False}, + {"target":"Tenements (MAP17) Blue","pro":False}, + {"target":"Tenements (MAP17) Main","pro":False}]}, + + # The Courtyard (MAP18) + {"name":"The Courtyard (MAP18) Main", + "connects_to_hub":True, + "episode":2, + "connections":[ + {"target":"The Courtyard (MAP18) Yellow","pro":False}, + {"target":"The Courtyard (MAP18) Blue","pro":False}]}, + {"name":"The Courtyard (MAP18) Blue", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"The Courtyard (MAP18) Main","pro":False}]}, + {"name":"The Courtyard (MAP18) Yellow", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"The Courtyard (MAP18) Main","pro":False}]}, + + # The Citadel (MAP19) + {"name":"The Citadel (MAP19) Main", + "connects_to_hub":True, + "episode":2, + "connections":[{"target":"The Citadel (MAP19) Red","pro":False}]}, + {"name":"The Citadel (MAP19) Red", + "connects_to_hub":False, + "episode":2, + "connections":[{"target":"The Citadel (MAP19) Main","pro":False}]}, + + # Gotcha! (MAP20) + {"name":"Gotcha! (MAP20) Main", + "connects_to_hub":True, + "episode":2, + "connections":[]}, + + # Nirvana (MAP21) + {"name":"Nirvana (MAP21) Main", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Nirvana (MAP21) Yellow","pro":False}]}, + {"name":"Nirvana (MAP21) Yellow", + "connects_to_hub":False, + "episode":3, + "connections":[ + {"target":"Nirvana (MAP21) Main","pro":False}, + {"target":"Nirvana (MAP21) Magenta","pro":False}]}, + {"name":"Nirvana (MAP21) Magenta", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Nirvana (MAP21) Yellow","pro":False}]}, + + # The Catacombs (MAP22) + {"name":"The Catacombs (MAP22) Main", + "connects_to_hub":True, + "episode":3, + "connections":[ + {"target":"The Catacombs (MAP22) Blue","pro":False}, + {"target":"The Catacombs (MAP22) Red","pro":False}]}, + {"name":"The Catacombs (MAP22) Blue", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Catacombs (MAP22) Main","pro":False}]}, + {"name":"The Catacombs (MAP22) Red", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Catacombs (MAP22) Main","pro":False}]}, + + # Barrels o Fun (MAP23) + {"name":"Barrels o Fun (MAP23) Main", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Barrels o Fun (MAP23) Yellow","pro":False}]}, + {"name":"Barrels o Fun (MAP23) Yellow", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Barrels o Fun (MAP23) Main","pro":False}]}, + + # The Chasm (MAP24) + {"name":"The Chasm (MAP24) Main", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"The Chasm (MAP24) Red","pro":False}]}, + {"name":"The Chasm (MAP24) Red", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Chasm (MAP24) Main","pro":False}]}, + + # Bloodfalls (MAP25) + {"name":"Bloodfalls (MAP25) Main", + "connects_to_hub":True, + "episode":3, + "connections":[{"target":"Bloodfalls (MAP25) Blue","pro":False}]}, + {"name":"Bloodfalls (MAP25) Blue", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Bloodfalls (MAP25) Main","pro":False}]}, + + # The Abandoned Mines (MAP26) + {"name":"The Abandoned Mines (MAP26) Main", + "connects_to_hub":True, + "episode":3, + "connections":[ + {"target":"The Abandoned Mines (MAP26) Yellow","pro":False}, + {"target":"The Abandoned Mines (MAP26) Red","pro":False}, + {"target":"The Abandoned Mines (MAP26) Blue","pro":False}]}, + {"name":"The Abandoned Mines (MAP26) Blue", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Abandoned Mines (MAP26) Main","pro":False}]}, + {"name":"The Abandoned Mines (MAP26) Yellow", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Abandoned Mines (MAP26) Main","pro":False}]}, + {"name":"The Abandoned Mines (MAP26) Red", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Abandoned Mines (MAP26) Main","pro":False}]}, + + # Monster Condo (MAP27) + {"name":"Monster Condo (MAP27) Main", + "connects_to_hub":True, + "episode":3, + "connections":[ + {"target":"Monster Condo (MAP27) Yellow","pro":False}, + {"target":"Monster Condo (MAP27) Red","pro":False}, + {"target":"Monster Condo (MAP27) Blue","pro":False}]}, + {"name":"Monster Condo (MAP27) Blue", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Monster Condo (MAP27) Main","pro":False}]}, + {"name":"Monster Condo (MAP27) Yellow", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Monster Condo (MAP27) Main","pro":False}]}, + {"name":"Monster Condo (MAP27) Red", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"Monster Condo (MAP27) Main","pro":False}]}, + + # The Spirit World (MAP28) + {"name":"The Spirit World (MAP28) Main", + "connects_to_hub":True, + "episode":3, + "connections":[ + {"target":"The Spirit World (MAP28) Yellow","pro":False}, + {"target":"The Spirit World (MAP28) Red","pro":False}]}, + {"name":"The Spirit World (MAP28) Yellow", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Spirit World (MAP28) Main","pro":False}]}, + {"name":"The Spirit World (MAP28) Red", + "connects_to_hub":False, + "episode":3, + "connections":[{"target":"The Spirit World (MAP28) Main","pro":False}]}, + + # The Living End (MAP29) + {"name":"The Living End (MAP29) Main", + "connects_to_hub":True, + "episode":3, + "connections":[]}, + + # Icon of Sin (MAP30) + {"name":"Icon of Sin (MAP30) Main", + "connects_to_hub":True, + "episode":3, + "connections":[]}, + + # Wolfenstein2 (MAP31) + {"name":"Wolfenstein2 (MAP31) Main", + "connects_to_hub":True, + "episode":4, + "connections":[]}, + + # Grosse2 (MAP32) + {"name":"Grosse2 (MAP32) Main", + "connects_to_hub":True, + "episode":4, + "connections":[]}, +] diff --git a/worlds/doom_ii/Rules.py b/worlds/doom_ii/Rules.py new file mode 100644 index 0000000000..89f3a10f9f --- /dev/null +++ b/worlds/doom_ii/Rules.py @@ -0,0 +1,501 @@ +# This file is auto generated. More info: https://github.com/Daivuk/apdoom + +from typing import TYPE_CHECKING +from worlds.generic.Rules import set_rule + +if TYPE_CHECKING: + from . import DOOM2World + + +def set_episode1_rules(player, world, pro): + # Entryway (MAP01) + set_rule(world.get_entrance("Hub -> Entryway (MAP01) Main", player), lambda state: + state.has("Entryway (MAP01)", player, 1)) + set_rule(world.get_entrance("Hub -> Entryway (MAP01) Main", player), lambda state: + state.has("Entryway (MAP01)", player, 1)) + + # Underhalls (MAP02) + set_rule(world.get_entrance("Hub -> Underhalls (MAP02) Main", player), lambda state: + state.has("Underhalls (MAP02)", player, 1)) + set_rule(world.get_entrance("Hub -> Underhalls (MAP02) Main", player), lambda state: + state.has("Underhalls (MAP02)", player, 1)) + set_rule(world.get_entrance("Hub -> Underhalls (MAP02) Main", player), lambda state: + state.has("Underhalls (MAP02)", player, 1)) + set_rule(world.get_entrance("Underhalls (MAP02) Main -> Underhalls (MAP02) Red", player), lambda state: + state.has("Underhalls (MAP02) - Red keycard", player, 1)) + set_rule(world.get_entrance("Underhalls (MAP02) Blue -> Underhalls (MAP02) Red", player), lambda state: + state.has("Underhalls (MAP02) - Blue keycard", player, 1)) + set_rule(world.get_entrance("Underhalls (MAP02) Red -> Underhalls (MAP02) Blue", player), lambda state: + state.has("Underhalls (MAP02) - Blue keycard", player, 1)) + + # The Gantlet (MAP03) + set_rule(world.get_entrance("Hub -> The Gantlet (MAP03) Main", player), lambda state: + (state.has("The Gantlet (MAP03)", player, 1)) and + (state.has("Shotgun", player, 1) or + state.has("Chaingun", player, 1) or + state.has("Super Shotgun", player, 1))) + set_rule(world.get_entrance("The Gantlet (MAP03) Main -> The Gantlet (MAP03) Blue", player), lambda state: + state.has("The Gantlet (MAP03) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Gantlet (MAP03) Blue -> The Gantlet (MAP03) Red", player), lambda state: + state.has("The Gantlet (MAP03) - Red keycard", player, 1)) + + # The Focus (MAP04) + set_rule(world.get_entrance("Hub -> The Focus (MAP04) Main", player), lambda state: + (state.has("The Focus (MAP04)", player, 1)) and + (state.has("Shotgun", player, 1) or + state.has("Chaingun", player, 1) or + state.has("Super Shotgun", player, 1))) + set_rule(world.get_entrance("The Focus (MAP04) Main -> The Focus (MAP04) Red", player), lambda state: + state.has("The Focus (MAP04) - Red keycard", player, 1)) + set_rule(world.get_entrance("The Focus (MAP04) Main -> The Focus (MAP04) Blue", player), lambda state: + state.has("The Focus (MAP04) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Focus (MAP04) Yellow -> The Focus (MAP04) Red", player), lambda state: + state.has("The Focus (MAP04) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Focus (MAP04) Red -> The Focus (MAP04) Yellow", player), lambda state: + state.has("The Focus (MAP04) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Focus (MAP04) Red -> The Focus (MAP04) Main", player), lambda state: + state.has("The Focus (MAP04) - Red keycard", player, 1)) + + # The Waste Tunnels (MAP05) + set_rule(world.get_entrance("Hub -> The Waste Tunnels (MAP05) Main", player), lambda state: + (state.has("The Waste Tunnels (MAP05)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Waste Tunnels (MAP05) Main -> The Waste Tunnels (MAP05) Red", player), lambda state: + state.has("The Waste Tunnels (MAP05) - Red keycard", player, 1)) + set_rule(world.get_entrance("The Waste Tunnels (MAP05) Main -> The Waste Tunnels (MAP05) Blue", player), lambda state: + state.has("The Waste Tunnels (MAP05) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Waste Tunnels (MAP05) Blue -> The Waste Tunnels (MAP05) Yellow", player), lambda state: + state.has("The Waste Tunnels (MAP05) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Waste Tunnels (MAP05) Blue -> The Waste Tunnels (MAP05) Main", player), lambda state: + state.has("The Waste Tunnels (MAP05) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Waste Tunnels (MAP05) Yellow -> The Waste Tunnels (MAP05) Blue", player), lambda state: + state.has("The Waste Tunnels (MAP05) - Yellow keycard", player, 1)) + + # The Crusher (MAP06) + set_rule(world.get_entrance("Hub -> The Crusher (MAP06) Main", player), lambda state: + (state.has("The Crusher (MAP06)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Crusher (MAP06) Main -> The Crusher (MAP06) Blue", player), lambda state: + state.has("The Crusher (MAP06) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Crusher (MAP06) Blue -> The Crusher (MAP06) Red", player), lambda state: + state.has("The Crusher (MAP06) - Red keycard", player, 1)) + set_rule(world.get_entrance("The Crusher (MAP06) Blue -> The Crusher (MAP06) Main", player), lambda state: + state.has("The Crusher (MAP06) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Crusher (MAP06) Yellow -> The Crusher (MAP06) Red", player), lambda state: + state.has("The Crusher (MAP06) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Crusher (MAP06) Red -> The Crusher (MAP06) Yellow", player), lambda state: + state.has("The Crusher (MAP06) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Crusher (MAP06) Red -> The Crusher (MAP06) Blue", player), lambda state: + state.has("The Crusher (MAP06) - Red keycard", player, 1)) + + # Dead Simple (MAP07) + set_rule(world.get_entrance("Hub -> Dead Simple (MAP07) Main", player), lambda state: + (state.has("Dead Simple (MAP07)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + + # Tricks and Traps (MAP08) + set_rule(world.get_entrance("Hub -> Tricks and Traps (MAP08) Main", player), lambda state: + (state.has("Tricks and Traps (MAP08)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Tricks and Traps (MAP08) Main -> Tricks and Traps (MAP08) Red", player), lambda state: + state.has("Tricks and Traps (MAP08) - Red skull key", player, 1)) + set_rule(world.get_entrance("Tricks and Traps (MAP08) Main -> Tricks and Traps (MAP08) Yellow", player), lambda state: + state.has("Tricks and Traps (MAP08) - Yellow skull key", player, 1)) + + # The Pit (MAP09) + set_rule(world.get_entrance("Hub -> The Pit (MAP09) Main", player), lambda state: + (state.has("The Pit (MAP09)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Pit (MAP09) Main -> The Pit (MAP09) Yellow", player), lambda state: + state.has("The Pit (MAP09) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Pit (MAP09) Main -> The Pit (MAP09) Blue", player), lambda state: + state.has("The Pit (MAP09) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Pit (MAP09) Yellow -> The Pit (MAP09) Main", player), lambda state: + state.has("The Pit (MAP09) - Yellow keycard", player, 1)) + + # Refueling Base (MAP10) + set_rule(world.get_entrance("Hub -> Refueling Base (MAP10) Main", player), lambda state: + (state.has("Refueling Base (MAP10)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Refueling Base (MAP10) Main -> Refueling Base (MAP10) Yellow", player), lambda state: + state.has("Refueling Base (MAP10) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("Refueling Base (MAP10) Yellow -> Refueling Base (MAP10) Yellow Blue", player), lambda state: + state.has("Refueling Base (MAP10) - Blue keycard", player, 1)) + + # Circle of Death (MAP11) + set_rule(world.get_entrance("Hub -> Circle of Death (MAP11) Main", player), lambda state: + (state.has("Circle of Death (MAP11)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Circle of Death (MAP11) Main -> Circle of Death (MAP11) Blue", player), lambda state: + state.has("Circle of Death (MAP11) - Blue keycard", player, 1)) + set_rule(world.get_entrance("Circle of Death (MAP11) Main -> Circle of Death (MAP11) Red", player), lambda state: + state.has("Circle of Death (MAP11) - Red keycard", player, 1)) + + +def set_episode2_rules(player, world, pro): + # The Factory (MAP12) + set_rule(world.get_entrance("Hub -> The Factory (MAP12) Main", player), lambda state: + (state.has("The Factory (MAP12)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Factory (MAP12) Main -> The Factory (MAP12) Yellow", player), lambda state: + state.has("The Factory (MAP12) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Factory (MAP12) Main -> The Factory (MAP12) Blue", player), lambda state: + state.has("The Factory (MAP12) - Blue keycard", player, 1)) + + # Downtown (MAP13) + set_rule(world.get_entrance("Hub -> Downtown (MAP13) Main", player), lambda state: + (state.has("Downtown (MAP13)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Downtown (MAP13) Main -> Downtown (MAP13) Yellow", player), lambda state: + state.has("Downtown (MAP13) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("Downtown (MAP13) Main -> Downtown (MAP13) Red", player), lambda state: + state.has("Downtown (MAP13) - Red keycard", player, 1)) + set_rule(world.get_entrance("Downtown (MAP13) Main -> Downtown (MAP13) Blue", player), lambda state: + state.has("Downtown (MAP13) - Blue keycard", player, 1)) + + # The Inmost Dens (MAP14) + set_rule(world.get_entrance("Hub -> The Inmost Dens (MAP14) Main", player), lambda state: + (state.has("The Inmost Dens (MAP14)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Inmost Dens (MAP14) Main -> The Inmost Dens (MAP14) Red", player), lambda state: + state.has("The Inmost Dens (MAP14) - Red skull key", player, 1)) + set_rule(world.get_entrance("The Inmost Dens (MAP14) Blue -> The Inmost Dens (MAP14) Red East", player), lambda state: + state.has("The Inmost Dens (MAP14) - Blue skull key", player, 1)) + set_rule(world.get_entrance("The Inmost Dens (MAP14) Red -> The Inmost Dens (MAP14) Main", player), lambda state: + state.has("The Inmost Dens (MAP14) - Red skull key", player, 1)) + set_rule(world.get_entrance("The Inmost Dens (MAP14) Red East -> The Inmost Dens (MAP14) Blue", player), lambda state: + state.has("The Inmost Dens (MAP14) - Blue skull key", player, 1)) + + # Industrial Zone (MAP15) + set_rule(world.get_entrance("Hub -> Industrial Zone (MAP15) Main", player), lambda state: + (state.has("Industrial Zone (MAP15)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Industrial Zone (MAP15) Main -> Industrial Zone (MAP15) Yellow East", player), lambda state: + state.has("Industrial Zone (MAP15) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("Industrial Zone (MAP15) Main -> Industrial Zone (MAP15) Yellow West", player), lambda state: + state.has("Industrial Zone (MAP15) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("Industrial Zone (MAP15) Blue -> Industrial Zone (MAP15) Yellow East", player), lambda state: + state.has("Industrial Zone (MAP15) - Blue keycard", player, 1)) + set_rule(world.get_entrance("Industrial Zone (MAP15) Yellow East -> Industrial Zone (MAP15) Blue", player), lambda state: + state.has("Industrial Zone (MAP15) - Blue keycard", player, 1)) + + # Suburbs (MAP16) + set_rule(world.get_entrance("Hub -> Suburbs (MAP16) Main", player), lambda state: + (state.has("Suburbs (MAP16)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Suburbs (MAP16) Main -> Suburbs (MAP16) Red", player), lambda state: + state.has("Suburbs (MAP16) - Red skull key", player, 1)) + set_rule(world.get_entrance("Suburbs (MAP16) Main -> Suburbs (MAP16) Blue", player), lambda state: + state.has("Suburbs (MAP16) - Blue skull key", player, 1)) + + # Tenements (MAP17) + set_rule(world.get_entrance("Hub -> Tenements (MAP17) Main", player), lambda state: + (state.has("Tenements (MAP17)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Tenements (MAP17) Main -> Tenements (MAP17) Red", player), lambda state: + state.has("Tenements (MAP17) - Red keycard", player, 1)) + set_rule(world.get_entrance("Tenements (MAP17) Red -> Tenements (MAP17) Yellow", player), lambda state: + state.has("Tenements (MAP17) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("Tenements (MAP17) Red -> Tenements (MAP17) Blue", player), lambda state: + state.has("Tenements (MAP17) - Blue keycard", player, 1)) + + # The Courtyard (MAP18) + set_rule(world.get_entrance("Hub -> The Courtyard (MAP18) Main", player), lambda state: + (state.has("The Courtyard (MAP18)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Courtyard (MAP18) Main -> The Courtyard (MAP18) Yellow", player), lambda state: + state.has("The Courtyard (MAP18) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("The Courtyard (MAP18) Main -> The Courtyard (MAP18) Blue", player), lambda state: + state.has("The Courtyard (MAP18) - Blue skull key", player, 1)) + set_rule(world.get_entrance("The Courtyard (MAP18) Blue -> The Courtyard (MAP18) Main", player), lambda state: + state.has("The Courtyard (MAP18) - Blue skull key", player, 1)) + set_rule(world.get_entrance("The Courtyard (MAP18) Yellow -> The Courtyard (MAP18) Main", player), lambda state: + state.has("The Courtyard (MAP18) - Yellow skull key", player, 1)) + + # The Citadel (MAP19) + set_rule(world.get_entrance("Hub -> The Citadel (MAP19) Main", player), lambda state: + (state.has("The Citadel (MAP19)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("The Citadel (MAP19) Main -> The Citadel (MAP19) Red", player), lambda state: + (state.has("The Citadel (MAP19) - Red skull key", player, 1)) and (state.has("The Citadel (MAP19) - Blue skull key", player, 1) or + state.has("The Citadel (MAP19) - Yellow skull key", player, 1))) + set_rule(world.get_entrance("The Citadel (MAP19) Red -> The Citadel (MAP19) Main", player), lambda state: + (state.has("The Citadel (MAP19) - Red skull key", player, 1)) and (state.has("The Citadel (MAP19) - Yellow skull key", player, 1) or + state.has("The Citadel (MAP19) - Blue skull key", player, 1))) + + # Gotcha! (MAP20) + set_rule(world.get_entrance("Hub -> Gotcha! (MAP20) Main", player), lambda state: + (state.has("Gotcha! (MAP20)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + + +def set_episode3_rules(player, world, pro): + # Nirvana (MAP21) + set_rule(world.get_entrance("Hub -> Nirvana (MAP21) Main", player), lambda state: + (state.has("Nirvana (MAP21)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Nirvana (MAP21) Main -> Nirvana (MAP21) Yellow", player), lambda state: + state.has("Nirvana (MAP21) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("Nirvana (MAP21) Yellow -> Nirvana (MAP21) Main", player), lambda state: + state.has("Nirvana (MAP21) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("Nirvana (MAP21) Yellow -> Nirvana (MAP21) Magenta", player), lambda state: + state.has("Nirvana (MAP21) - Red skull key", player, 1) and + state.has("Nirvana (MAP21) - Blue skull key", player, 1)) + set_rule(world.get_entrance("Nirvana (MAP21) Magenta -> Nirvana (MAP21) Yellow", player), lambda state: + state.has("Nirvana (MAP21) - Red skull key", player, 1) and + state.has("Nirvana (MAP21) - Blue skull key", player, 1)) + + # The Catacombs (MAP22) + set_rule(world.get_entrance("Hub -> The Catacombs (MAP22) Main", player), lambda state: + (state.has("The Catacombs (MAP22)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("BFG9000", player, 1) or + state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1))) + set_rule(world.get_entrance("The Catacombs (MAP22) Main -> The Catacombs (MAP22) Blue", player), lambda state: + state.has("The Catacombs (MAP22) - Blue skull key", player, 1)) + set_rule(world.get_entrance("The Catacombs (MAP22) Main -> The Catacombs (MAP22) Red", player), lambda state: + state.has("The Catacombs (MAP22) - Red skull key", player, 1)) + set_rule(world.get_entrance("The Catacombs (MAP22) Red -> The Catacombs (MAP22) Main", player), lambda state: + state.has("The Catacombs (MAP22) - Red skull key", player, 1)) + + # Barrels o Fun (MAP23) + set_rule(world.get_entrance("Hub -> Barrels o Fun (MAP23) Main", player), lambda state: + (state.has("Barrels o Fun (MAP23)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + set_rule(world.get_entrance("Barrels o Fun (MAP23) Main -> Barrels o Fun (MAP23) Yellow", player), lambda state: + state.has("Barrels o Fun (MAP23) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("Barrels o Fun (MAP23) Yellow -> Barrels o Fun (MAP23) Main", player), lambda state: + state.has("Barrels o Fun (MAP23) - Yellow skull key", player, 1)) + + # The Chasm (MAP24) + set_rule(world.get_entrance("Hub -> The Chasm (MAP24) Main", player), lambda state: + state.has("The Chasm (MAP24)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Super Shotgun", player, 1)) + set_rule(world.get_entrance("The Chasm (MAP24) Main -> The Chasm (MAP24) Red", player), lambda state: + state.has("The Chasm (MAP24) - Red keycard", player, 1)) + set_rule(world.get_entrance("The Chasm (MAP24) Red -> The Chasm (MAP24) Main", player), lambda state: + state.has("The Chasm (MAP24) - Red keycard", player, 1)) + + # Bloodfalls (MAP25) + set_rule(world.get_entrance("Hub -> Bloodfalls (MAP25) Main", player), lambda state: + state.has("Bloodfalls (MAP25)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Super Shotgun", player, 1)) + set_rule(world.get_entrance("Bloodfalls (MAP25) Main -> Bloodfalls (MAP25) Blue", player), lambda state: + state.has("Bloodfalls (MAP25) - Blue skull key", player, 1)) + set_rule(world.get_entrance("Bloodfalls (MAP25) Blue -> Bloodfalls (MAP25) Main", player), lambda state: + state.has("Bloodfalls (MAP25) - Blue skull key", player, 1)) + + # The Abandoned Mines (MAP26) + set_rule(world.get_entrance("Hub -> The Abandoned Mines (MAP26) Main", player), lambda state: + state.has("The Abandoned Mines (MAP26)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("Super Shotgun", player, 1)) + set_rule(world.get_entrance("The Abandoned Mines (MAP26) Main -> The Abandoned Mines (MAP26) Yellow", player), lambda state: + state.has("The Abandoned Mines (MAP26) - Yellow keycard", player, 1)) + set_rule(world.get_entrance("The Abandoned Mines (MAP26) Main -> The Abandoned Mines (MAP26) Red", player), lambda state: + state.has("The Abandoned Mines (MAP26) - Red keycard", player, 1)) + set_rule(world.get_entrance("The Abandoned Mines (MAP26) Main -> The Abandoned Mines (MAP26) Blue", player), lambda state: + state.has("The Abandoned Mines (MAP26) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Abandoned Mines (MAP26) Blue -> The Abandoned Mines (MAP26) Main", player), lambda state: + state.has("The Abandoned Mines (MAP26) - Blue keycard", player, 1)) + set_rule(world.get_entrance("The Abandoned Mines (MAP26) Yellow -> The Abandoned Mines (MAP26) Main", player), lambda state: + state.has("The Abandoned Mines (MAP26) - Yellow keycard", player, 1)) + + # Monster Condo (MAP27) + set_rule(world.get_entrance("Hub -> Monster Condo (MAP27) Main", player), lambda state: + state.has("Monster Condo (MAP27)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Super Shotgun", player, 1)) + set_rule(world.get_entrance("Monster Condo (MAP27) Main -> Monster Condo (MAP27) Yellow", player), lambda state: + state.has("Monster Condo (MAP27) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("Monster Condo (MAP27) Main -> Monster Condo (MAP27) Red", player), lambda state: + state.has("Monster Condo (MAP27) - Red skull key", player, 1)) + set_rule(world.get_entrance("Monster Condo (MAP27) Main -> Monster Condo (MAP27) Blue", player), lambda state: + state.has("Monster Condo (MAP27) - Blue skull key", player, 1)) + set_rule(world.get_entrance("Monster Condo (MAP27) Red -> Monster Condo (MAP27) Main", player), lambda state: + state.has("Monster Condo (MAP27) - Red skull key", player, 1)) + + # The Spirit World (MAP28) + set_rule(world.get_entrance("Hub -> The Spirit World (MAP28) Main", player), lambda state: + state.has("The Spirit World (MAP28)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Super Shotgun", player, 1)) + set_rule(world.get_entrance("The Spirit World (MAP28) Main -> The Spirit World (MAP28) Yellow", player), lambda state: + state.has("The Spirit World (MAP28) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("The Spirit World (MAP28) Main -> The Spirit World (MAP28) Red", player), lambda state: + state.has("The Spirit World (MAP28) - Red skull key", player, 1)) + set_rule(world.get_entrance("The Spirit World (MAP28) Yellow -> The Spirit World (MAP28) Main", player), lambda state: + state.has("The Spirit World (MAP28) - Yellow skull key", player, 1)) + set_rule(world.get_entrance("The Spirit World (MAP28) Red -> The Spirit World (MAP28) Main", player), lambda state: + state.has("The Spirit World (MAP28) - Red skull key", player, 1)) + + # The Living End (MAP29) + set_rule(world.get_entrance("Hub -> The Living End (MAP29) Main", player), lambda state: + state.has("The Living End (MAP29)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Super Shotgun", player, 1)) + + # Icon of Sin (MAP30) + set_rule(world.get_entrance("Hub -> Icon of Sin (MAP30) Main", player), lambda state: + state.has("Icon of Sin (MAP30)", player, 1) and + state.has("Rocket launcher", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Plasma gun", player, 1) and + state.has("BFG9000", player, 1) and + state.has("Super Shotgun", player, 1)) + + +def set_episode4_rules(player, world, pro): + # Wolfenstein2 (MAP31) + set_rule(world.get_entrance("Hub -> Wolfenstein2 (MAP31) Main", player), lambda state: + (state.has("Wolfenstein2 (MAP31)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + + # Grosse2 (MAP32) + set_rule(world.get_entrance("Hub -> Grosse2 (MAP32) Main", player), lambda state: + (state.has("Grosse2 (MAP32)", player, 1) and + state.has("Shotgun", player, 1) and + state.has("Chaingun", player, 1) and + state.has("Super Shotgun", player, 1)) and + (state.has("Rocket launcher", player, 1) or + state.has("Plasma gun", player, 1) or + state.has("BFG9000", player, 1))) + + +def set_rules(doom_ii_world: "DOOM2World", included_episodes, pro): + player = doom_ii_world.player + world = doom_ii_world.multiworld + + if included_episodes[0]: + set_episode1_rules(player, world, pro) + if included_episodes[1]: + set_episode2_rules(player, world, pro) + if included_episodes[2]: + set_episode3_rules(player, world, pro) + if included_episodes[3]: + set_episode4_rules(player, world, pro) diff --git a/worlds/doom_ii/__init__.py b/worlds/doom_ii/__init__.py new file mode 100644 index 0000000000..22dee2ab74 --- /dev/null +++ b/worlds/doom_ii/__init__.py @@ -0,0 +1,267 @@ +import functools +import logging +from typing import Any, Dict, List + +from BaseClasses import Entrance, CollectionState, Item, Location, MultiWorld, Region, Tutorial +from worlds.AutoWorld import WebWorld, World +from . import Items, Locations, Maps, Regions, Rules +from .Options import DOOM2Options + +logger = logging.getLogger("DOOM II") + +DOOM_TYPE_LEVEL_COMPLETE = -2 +DOOM_TYPE_COMPUTER_AREA_MAP = 2026 + + +class DOOM2Location(Location): + game: str = "DOOM II" + + +class DOOM2Item(Item): + game: str = "DOOM II" + + +class DOOM2Web(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the DOOM II randomizer connected to an Archipelago Multiworld", + "English", + "setup_en.md", + "setup/en", + ["Daivuk"] + )] + theme = "dirt" + + +class DOOM2World(World): + """ + Doom II, also known as Doom II: Hell on Earth, is a first-person shooter game by id Software. + It was released for MS-DOS in 1994. + Compared to its predecessor, Doom II features larger levels, new enemies, a new "super shotgun" weapon + """ + options_dataclass = DOOM2Options + options: DOOM2Options + game = "DOOM II" + web = DOOM2Web() + data_version = 3 + required_client_version = (0, 3, 9) + + item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()} + item_name_groups = Items.item_name_groups + + location_name_to_id = {data["name"]: loc_id for loc_id, data in Locations.location_table.items()} + location_name_groups = Locations.location_name_groups + + starting_level_for_episode: List[str] = [ + "Entryway (MAP01)", + "The Factory (MAP12)", + "Nirvana (MAP21)" + ] + + # Item ratio that scales depending on episode count. These are the ratio for 3 episode. In DOOM1. + # The ratio have been tweaked seem, and feel good. + items_ratio: Dict[str, float] = { + "Armor": 41, + "Mega Armor": 25, + "Berserk": 12, + "Invulnerability": 10, + "Partial invisibility": 18, + "Supercharge": 28, + "Medikit": 15, + "Box of bullets": 13, + "Box of rockets": 13, + "Box of shotgun shells": 13, + "Energy cell pack": 10 + } + + def __init__(self, world: MultiWorld, player: int): + self.included_episodes = [1, 1, 1, 0] + self.location_count = 0 + + super().__init__(world, player) + + def get_episode_count(self): + # Don't include 4th, those are secret levels they are additive + return sum(self.included_episodes[:3]) + + def generate_early(self): + # Cache which episodes are included + self.included_episodes[0] = self.options.episode1.value + self.included_episodes[1] = self.options.episode2.value + self.included_episodes[2] = self.options.episode3.value + self.included_episodes[3] = self.options.episode4.value # 4th episode are secret levels + + # If no episodes selected, select Episode 1 + if self.get_episode_count() == 0: + self.included_episodes[0] = 1 + + def create_regions(self): + pro = self.options.pro.value + + # Main regions + menu_region = Region("Menu", self.player, self.multiworld) + hub_region = Region("Hub", self.player, self.multiworld) + self.multiworld.regions += [menu_region, hub_region] + menu_region.add_exits(["Hub"]) + + # Create regions and locations + main_regions = [] + connections = [] + for region_dict in Regions.regions: + if not self.included_episodes[region_dict["episode"] - 1]: + continue + + region_name = region_dict["name"] + if region_dict["connects_to_hub"]: + main_regions.append(region_name) + + region = Region(region_name, self.player, self.multiworld) + region.add_locations({ + loc["name"]: loc_id + for loc_id, loc in Locations.location_table.items() + if loc["region"] == region_name and self.included_episodes[loc["episode"] - 1] + }, DOOM2Location) + + self.multiworld.regions.append(region) + + for connection_dict in region_dict["connections"]: + # Check if it's a pro-only connection + if connection_dict["pro"] and not pro: + continue + connections.append((region, connection_dict["target"])) + + # Connect main regions to Hub + hub_region.add_exits(main_regions) + + # Do the other connections between regions (They are not all both ways) + for connection in connections: + source = connection[0] + target = self.multiworld.get_region(connection[1], self.player) + + entrance = Entrance(self.player, f"{source.name} -> {target.name}", source) + source.exits.append(entrance) + entrance.connect(target) + + # Sum locations for items creation + self.location_count = len(self.multiworld.get_locations(self.player)) + + def completion_rule(self, state: CollectionState): + for map_name in Maps.map_names: + if map_name + " - Exit" not in self.location_name_to_id: + continue + + # Exit location names are in form: Entryway (MAP01) - Exit + loc = Locations.location_table[self.location_name_to_id[map_name + " - Exit"]] + if not self.included_episodes[loc["episode"] - 1]: + continue + + # Map complete item names are in form: Entryway (MAP01) - Complete + if not state.has(map_name + " - Complete", self.player, 1): + return False + + return True + + def set_rules(self): + pro = self.options.pro.value + allow_death_logic = self.options.allow_death_logic.value + + Rules.set_rules(self, self.included_episodes, pro) + self.multiworld.completion_condition[self.player] = lambda state: self.completion_rule(state) + + # Forbid progression items to locations that can be missed and can't be picked up. (e.g. One-time timed + # platform) Unless the user allows for it. + if not allow_death_logic: + for death_logic_location in Locations.death_logic_locations: + self.multiworld.exclude_locations[self.player].value.add(death_logic_location) + + def create_item(self, name: str) -> DOOM2Item: + item_id: int = self.item_name_to_id[name] + return DOOM2Item(name, Items.item_table[item_id]["classification"], item_id, self.player) + + def create_items(self): + itempool: List[DOOM2Item] = [] + start_with_computer_area_maps: bool = self.options.start_with_computer_area_maps.value + + # Items + for item_id, item in Items.item_table.items(): + if item["doom_type"] == DOOM_TYPE_LEVEL_COMPLETE: + continue # We'll fill it manually later + + if item["doom_type"] == DOOM_TYPE_COMPUTER_AREA_MAP and start_with_computer_area_maps: + continue # We'll fill it manually, and we will put fillers in place + + if item["episode"] != -1 and not self.included_episodes[item["episode"] - 1]: + continue + + count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1 + itempool += [self.create_item(item["name"]) for _ in range(count)] + + # Place end level items in locked locations + for map_name in Maps.map_names: + loc_name = map_name + " - Exit" + item_name = map_name + " - Complete" + + if loc_name not in self.location_name_to_id: + continue + + if item_name not in self.item_name_to_id: + continue + + loc = Locations.location_table[self.location_name_to_id[loc_name]] + if not self.included_episodes[loc["episode"] - 1]: + continue + + self.multiworld.get_location(loc_name, self.player).place_locked_item(self.create_item(item_name)) + self.location_count -= 1 + + # Give starting levels right away + for i in range(len(self.starting_level_for_episode)): + if self.included_episodes[i]: + self.multiworld.push_precollected(self.create_item(self.starting_level_for_episode[i])) + + # Give Computer area maps if option selected + if start_with_computer_area_maps: + for item_id, item_dict in Items.item_table.items(): + item_episode = item_dict["episode"] + if item_episode > 0: + if item_dict["doom_type"] == DOOM_TYPE_COMPUTER_AREA_MAP and self.included_episodes[item_episode - 1]: + self.multiworld.push_precollected(self.create_item(item_dict["name"])) + + # Fill the rest starting with powerups, then fillers + self.create_ratioed_items("Armor", itempool) + self.create_ratioed_items("Mega Armor", itempool) + self.create_ratioed_items("Berserk", itempool) + self.create_ratioed_items("Invulnerability", itempool) + self.create_ratioed_items("Partial invisibility", itempool) + self.create_ratioed_items("Supercharge", itempool) + + while len(itempool) < self.location_count: + itempool.append(self.create_item(self.get_filler_item_name())) + + # add itempool to multiworld + self.multiworld.itempool += itempool + + def get_filler_item_name(self): + return self.multiworld.random.choice([ + "Medikit", + "Box of bullets", + "Box of rockets", + "Box of shotgun shells", + "Energy cell pack" + ]) + + def create_ratioed_items(self, item_name: str, itempool: List[DOOM2Item]): + remaining_loc = self.location_count - len(itempool) + ep_count = self.get_episode_count() + + # Was balanced based on DOOM 1993's first 3 episodes + count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3)))) + if count == 0: + logger.warning("Warning, no ", item_name, " will be placed.") + return + + for i in range(count): + itempool.append(self.create_item(item_name)) + + def fill_slot_data(self) -> Dict[str, Any]: + return self.options.as_dict("difficulty", "random_monsters", "random_pickups", "random_music", "flip_levels", "allow_death_logic", "pro", "death_link", "reset_level_on_death", "episode1", "episode2", "episode3", "episode4") diff --git a/worlds/doom_ii/docs/en_DOOM II.md b/worlds/doom_ii/docs/en_DOOM II.md new file mode 100644 index 0000000000..d561745b76 --- /dev/null +++ b/worlds/doom_ii/docs/en_DOOM II.md @@ -0,0 +1,23 @@ +# DOOM II + +## Where is the settings page? + +The [player settings page](../player-settings) contains the options needed to configure your game session. + +## What does randomization do to this game? + +Guns, keycards, and level unlocks have been randomized. Typically, you will end up playing different levels out of order to find your keycards and level unlocks and eventually complete your game. + +Maps can be selected on a level select screen. You can exit a level at any time by visiting the hub station at the beginning of each level. The state of each level is saved and restored upon re-entering the level. + +## What is the goal? + +The goal is to complete every level. + +## What is a "check" in DOOM II? + +Guns, keycards, and powerups have been replaced with Archipelago checks. The switch at the end of each level is also a check. + +## What "items" can you unlock in DOOM II? + +Keycards and level unlocks are your main progression items. Gun unlocks and some upgrades are your useful items. Temporary powerups, ammo, healing, and armor are filler items. diff --git a/worlds/doom_ii/docs/setup_en.md b/worlds/doom_ii/docs/setup_en.md new file mode 100644 index 0000000000..321d440ea6 --- /dev/null +++ b/worlds/doom_ii/docs/setup_en.md @@ -0,0 +1,51 @@ +# DOOM II Randomizer Setup + +## Required Software + +- [DOOM II (e.g. Steam version)](https://store.steampowered.com/app/2300/DOOM_II/) +- [Archipelago Crispy DOOM](https://github.com/Daivuk/apdoom/releases) + +## Optional Software + +- [ArchipelagoTextClient](https://github.com/ArchipelagoMW/Archipelago/releases) + +## Installing AP Doom +1. Download [APDOOM.zip](https://github.com/Daivuk/apdoom/releases) and extract it. +2. Copy DOOM2.WAD from your steam install into the 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*. + +## Joining a MultiWorld Game + +1. Launch apdoom-launcher.exe +2. Select `DOOM II` from the drop-down +3. Enter the Archipelago server address, slot name, and password (if you have one) +4. Press "Launch DOOM" +5. Enjoy! + +To continue a game, follow the same connection steps. +Connecting with a different seed won't erase your progress in other seeds. + +## Archipelago Text Client + +We recommend having Archipelago's Text Client open on the side to keep track of what items you receive and send. +APDOOM has in-game messages, +but they disappear quickly and there's no reasonable way to check your message history in-game. + +### Hinting + +To hint from in-game, use the chat (Default key: 'T'). Hinting from DOOM II can be difficult because names are rather long and contain special characters. For example: +``` +!hint Underhalls (MAP02) - Red keycard +``` +The game has a hint helper implemented, where you can simply type this: +``` +!hint map02 red +``` +For this to work, include the map short name (`MAP01`), followed by one of the keywords: `map`, `blue`, `yellow`, `red`. + +## Auto-Tracking + +APDOOM has a functional map tracker integrated into the level select screen. +It tells you which levels you have unlocked, which keys you have for each level, which levels have been completed, +and how many of the checks you have completed in each level. diff --git a/worlds/factorio/Locations.py b/worlds/factorio/Locations.py index f9db5f4a2b..52f0954cba 100644 --- a/worlds/factorio/Locations.py +++ b/worlds/factorio/Locations.py @@ -3,18 +3,13 @@ from typing import Dict, List from .Technologies import factorio_base_id from .Options import MaxSciencePack -boundary: int = 0xff -total_locations: int = 0xff - -assert total_locations <= boundary - def make_pools() -> Dict[str, List[str]]: pools: Dict[str, List[str]] = {} for i, pack in enumerate(MaxSciencePack.get_ordered_science_packs(), start=1): - max_needed: int = 0xff + max_needed: int = 999 prefix: str = f"AP-{i}-" - pools[pack] = [prefix + hex(x)[2:].upper().zfill(2) for x in range(1, max_needed + 1)] + pools[pack] = [prefix + str(x).upper().zfill(3) for x in range(1, max_needed + 1)] return pools diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index 270e7dacf0..d7b3d4b1eb 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -5,7 +5,7 @@ import os import shutil import threading import zipfile -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Any, List, Callable, Tuple, Union import jinja2 @@ -24,6 +24,7 @@ data_template: Optional[jinja2.Template] = None data_final_template: Optional[jinja2.Template] = None locale_template: Optional[jinja2.Template] = None control_template: Optional[jinja2.Template] = None +settings_template: Optional[jinja2.Template] = None template_load_lock = threading.Lock() @@ -62,15 +63,24 @@ recipe_time_ranges = { class FactorioModFile(worlds.Files.APContainer): game = "Factorio" compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives + writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]] + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.writing_tasks = [] def write_contents(self, opened_zipfile: zipfile.ZipFile): # directory containing Factorio mod has to come first, or Factorio won't recognize this file as a mod. mod_dir = self.path[:-4] # cut off .zip for root, dirs, files in os.walk(mod_dir): for file in files: - opened_zipfile.write(os.path.join(root, file), - os.path.relpath(os.path.join(root, file), + filename = os.path.join(root, file) + opened_zipfile.write(filename, + os.path.relpath(filename, os.path.join(mod_dir, '..'))) + for task in self.writing_tasks: + target, content = task() + opened_zipfile.writestr(target, content) # now we can add extras. super(FactorioModFile, self).write_contents(opened_zipfile) @@ -98,6 +108,7 @@ def generate_mod(world: "Factorio", output_directory: str): locations = [(location, location.item) for location in world.science_locations] mod_name = f"AP-{multiworld.seed_name}-P{player}-{multiworld.get_file_safe_player_name(player)}" + versioned_mod_name = mod_name + "_" + Utils.__version__ random = multiworld.per_slot_randoms[player] @@ -153,48 +164,40 @@ def generate_mod(world: "Factorio", output_directory: str): template_data["free_sample_blacklist"].update({item: 1 for item in multiworld.free_sample_blacklist[player].value}) template_data["free_sample_blacklist"].update({item: 0 for item in multiworld.free_sample_whitelist[player].value}) - control_code = control_template.render(**template_data) - data_template_code = data_template.render(**template_data) - data_final_fixes_code = data_final_template.render(**template_data) - settings_code = settings_template.render(**template_data) - - mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__) - en_locale_dir = os.path.join(mod_dir, "locale", "en") - os.makedirs(en_locale_dir, exist_ok=True) + zf_path = os.path.join(output_directory, versioned_mod_name + ".zip") + mod = FactorioModFile(zf_path, player=player, player_name=multiworld.player_name[player]) if world.zip_path: - # Maybe investigate read from zip, write to zip, without temp file? with zipfile.ZipFile(world.zip_path) as zf: for file in zf.infolist(): if not file.is_dir() and "/data/mod/" in file.filename: path_part = Utils.get_text_after(file.filename, "/data/mod/") - target = os.path.join(mod_dir, path_part) - os.makedirs(os.path.split(target)[0], exist_ok=True) - - with open(target, "wb") as f: - f.write(zf.read(file)) + mod.writing_tasks.append(lambda arcpath=versioned_mod_name+"/"+path_part, content=zf.read(file): + (arcpath, content)) else: - shutil.copytree(os.path.join(os.path.dirname(__file__), "data", "mod"), mod_dir, dirs_exist_ok=True) + basepath = os.path.join(os.path.dirname(__file__), "data", "mod") + for dirpath, dirnames, filenames in os.walk(basepath): + base_arc_path = (versioned_mod_name+"/"+os.path.relpath(dirpath, basepath)).rstrip("/.\\") + for filename in filenames: + mod.writing_tasks.append(lambda arcpath=base_arc_path+"/"+filename, + file_path=os.path.join(dirpath, filename): + (arcpath, open(file_path, "rb").read())) + + mod.writing_tasks.append(lambda: (versioned_mod_name + "/data.lua", + data_template.render(**template_data))) + mod.writing_tasks.append(lambda: (versioned_mod_name + "/data-final-fixes.lua", + data_final_template.render(**template_data))) + mod.writing_tasks.append(lambda: (versioned_mod_name + "/control.lua", + control_template.render(**template_data))) + mod.writing_tasks.append(lambda: (versioned_mod_name + "/settings.lua", + settings_template.render(**template_data))) + mod.writing_tasks.append(lambda: (versioned_mod_name + "/locale/en/locale.cfg", + locale_template.render(**template_data))) - with open(os.path.join(mod_dir, "data.lua"), "wt") as f: - f.write(data_template_code) - with open(os.path.join(mod_dir, "data-final-fixes.lua"), "wt") as f: - f.write(data_final_fixes_code) - with open(os.path.join(mod_dir, "control.lua"), "wt") as f: - f.write(control_code) - with open(os.path.join(mod_dir, "settings.lua"), "wt") as f: - f.write(settings_code) - locale_content = locale_template.render(**template_data) - with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f: - f.write(locale_content) info = base_info.copy() info["name"] = mod_name - with open(os.path.join(mod_dir, "info.json"), "wt") as f: - json.dump(info, f, indent=4) + mod.writing_tasks.append(lambda: (versioned_mod_name + "/info.json", + json.dumps(info, indent=4))) - # zip the result - zf_path = os.path.join(mod_dir + ".zip") - mod = FactorioModFile(zf_path, player=player, player_name=multiworld.player_name[player]) + # write the mod file mod.write() - - shutil.rmtree(mod_dir) diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 18eee67e03..3429ebbd42 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -2,7 +2,7 @@ from __future__ import annotations import typing import datetime -from Options import Choice, OptionDict, OptionSet, ItemDict, Option, DefaultOnToggle, Range, DeathLink, Toggle, \ +from Options import Choice, OptionDict, OptionSet, Option, DefaultOnToggle, Range, DeathLink, Toggle, \ StartInventoryPool from schema import Schema, Optional, And, Or @@ -207,11 +207,10 @@ class RecipeIngredientsOffset(Range): range_end = 5 -class FactorioStartItems(ItemDict): +class FactorioStartItems(OptionDict): """Mapping of Factorio internal item-name to amount granted on start.""" display_name = "Starting Items" - verify_item_name = False - default = {"burner-mining-drill": 19, "stone-furnace": 19} + default = {"burner-mining-drill": 4, "stone-furnace": 4, "raw-fish": 50} class FactorioFreeSampleBlacklist(OptionSet): diff --git a/worlds/factorio/Technologies.py b/worlds/factorio/Technologies.py index d68c6f2f77..096396c0e7 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 @@ pool = ThreadPoolExecutor(1) 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/__init__.py b/worlds/factorio/__init__.py index 8308bb2d65..17f3163e90 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -246,7 +246,8 @@ class Factorio(World): location.access_rule = lambda state, ingredient=ingredient, custom_recipe=custom_recipe: \ (ingredient not in technology_table or state.has(ingredient, player)) and \ all(state.has(technology.name, player) for sub_ingredient in custom_recipe.ingredients - for technology in required_technologies[sub_ingredient]) + for technology in required_technologies[sub_ingredient]) and \ + all(state.has(technology.name, player) for technology in required_technologies[custom_recipe.crafting_machine]) else: location.access_rule = lambda state, ingredient=ingredient: \ all(state.has(technology.name, player) for technology in required_technologies[ingredient]) @@ -541,7 +542,7 @@ class FactorioScienceLocation(FactorioLocation): super(FactorioScienceLocation, self).__init__(player, name, address, parent) # "AP-{Complexity}-{Cost}" self.complexity = int(self.name[3]) - 1 - self.rel_cost = int(self.name[5:], 16) + self.rel_cost = int(self.name[5:]) self.ingredients = {Factorio.ordered_science_packs[self.complexity]: 1} for complexity in range(self.complexity): diff --git a/worlds/factorio/data/mod/graphics/icons/ap.png b/worlds/factorio/data/mod/graphics/icons/ap.png index 8f0da105a1..fa6b80ccca 100644 Binary files a/worlds/factorio/data/mod/graphics/icons/ap.png and b/worlds/factorio/data/mod/graphics/icons/ap.png differ diff --git a/worlds/factorio/data/mod/graphics/icons/ap_unimportant.png b/worlds/factorio/data/mod/graphics/icons/ap_unimportant.png index 8471317a93..68ee52a5e8 100644 Binary files a/worlds/factorio/data/mod/graphics/icons/ap_unimportant.png and b/worlds/factorio/data/mod/graphics/icons/ap_unimportant.png differ diff --git a/worlds/factorio/data/mod/info.json b/worlds/factorio/data/mod/info.json deleted file mode 100644 index 70a9518344..0000000000 --- a/worlds/factorio/data/mod/info.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "archipelago-client", - "version": "0.0.1", - "title": "Archipelago", - "author": "Berserker and Dewiniaid", - "homepage": "https://archipelago.gg", - "description": "Integration client for the Archipelago Randomizer", - "factorio_version": "1.1", - "dependencies": [ - "base >= 1.1.0", - "? science-not-invited", - "? factory-levels" - ] -} diff --git a/worlds/factorio/docs/en_Factorio.md b/worlds/factorio/docs/en_Factorio.md index 61bceb3820..dbc33d05df 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 09ad431a21..b6d4545925 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/ff1/__init__.py b/worlds/ff1/__init__.py index 56b41d62d0..4ff361c072 100644 --- a/worlds/ff1/__init__.py +++ b/worlds/ff1/__init__.py @@ -14,7 +14,7 @@ class FF1Settings(settings.Group): class FF1Web(WebWorld): - settings_page = "https://finalfantasyrandomizer.com/" + options_page = "https://finalfantasyrandomizer.com/" tutorials = [Tutorial( "Multiworld Setup Guide", "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.", @@ -74,6 +74,7 @@ class FF1World(World): items = get_options(self.multiworld, 'items', self.player) goal_rule = generate_rule([[name for name in items.keys() if name in FF1_PROGRESSION_LIST and name != "Shard"]], self.player) + terminated_event.access_rule = goal_rule if "Shard" in items.keys(): def goal_rule_and_shards(state): return goal_rule(state) and state.has("Shard", self.player, 32) @@ -91,7 +92,7 @@ class FF1World(World): 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 29d4d29f80..59fa85d916 100644 --- a/worlds/ff1/docs/en_Final Fantasy.md +++ b/worlds/ff1/docs/en_Final Fantasy.md @@ -24,3 +24,9 @@ All items can appear in other players worlds, including consumables, shards, wea 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 commands are only available when using the FF1Client for the Final Fantasy Randomizer. + +- `/nes` Shows the current status of the NES connection. +- `/toggle_msgs` Toggle displaying messages in EmuHawk diff --git a/worlds/ffmq/Client.py b/worlds/ffmq/Client.py new file mode 100644 index 0000000000..c53f275017 --- /dev/null +++ b/worlds/ffmq/Client.py @@ -0,0 +1,119 @@ + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Regions import offset +import logging + +snes_logger = logging.getLogger("SNES") + +ROM_NAME = (0x7FC0, 0x7FD4 + 1 - 0x7FC0) + +READ_DATA_START = 0xF50EA8 +READ_DATA_END = 0xF50FE7 + 1 + +GAME_FLAGS = (0xF50EA8, 64) +COMPLETED_GAME = (0xF50F22, 1) +BATTLEFIELD_DATA = (0xF50FD4, 20) + +RECEIVED_DATA = (0xE01FF0, 3) + +ITEM_CODE_START = 0x420000 + +IN_GAME_FLAG = (4 * 8) + 2 + +NPC_CHECKS = { + 4325676: ((6 * 8) + 4, False), # Old Man Level Forest + 4325677: ((3 * 8) + 6, True), # Kaeli Level Forest + 4325678: ((25 * 8) + 1, True), # Tristam + 4325680: ((26 * 8) + 0, True), # Aquaria Vendor Girl + 4325681: ((29 * 8) + 2, True), # Phoebe Wintry Cave + 4325682: ((25 * 8) + 6, False), # Mysterious Man (Life Temple) + 4325683: ((29 * 8) + 3, True), # Reuben Mine + 4325684: ((29 * 8) + 7, True), # Spencer + 4325685: ((29 * 8) + 6, False), # Venus Chest + 4325686: ((29 * 8) + 1, True), # Fireburg Tristam + 4325687: ((26 * 8) + 1, True), # Fireburg Vendor Girl + 4325688: ((14 * 8) + 4, True), # MegaGrenade Dude + 4325689: ((29 * 8) + 5, False), # Tristam's Chest + 4325690: ((29 * 8) + 4, True), # Arion + 4325691: ((29 * 8) + 0, True), # Windia Kaeli + 4325692: ((26 * 8) + 2, True), # Windia Vendor Girl + +} + + +def get_flag(data, flag): + byte = int(flag / 8) + bit = int(0x80 / (2 ** (flag % 8))) + return (data[byte] & bit) > 0 + + +class FFMQClient(SNIClient): + game = "Final Fantasy Mystic Quest" + + async def validate_rom(self, ctx): + from SNIClient import snes_read + rom_name = await snes_read(ctx, *ROM_NAME) + if rom_name is None: + return False + if rom_name[:2] != b"MQ": + return False + + ctx.rom = rom_name + ctx.game = self.game + ctx.items_handling = 0b001 + return True + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + check_1 = await snes_read(ctx, 0xF53749, 1) + received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1]) + data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START) + check_2 = await snes_read(ctx, 0xF53749, 1) + if check_1 == b'\x00' or check_2 == b'\x00': + return + + def get_range(data_range): + return data[data_range[0] - READ_DATA_START:data_range[0] + data_range[1] - READ_DATA_START] + completed_game = get_range(COMPLETED_GAME) + battlefield_data = get_range(BATTLEFIELD_DATA) + game_flags = get_range(GAME_FLAGS) + + if game_flags is None: + return + if not get_flag(game_flags, IN_GAME_FLAG): + return + + if not ctx.finished_game: + if completed_game[0] & 0x80 and game_flags[30] & 0x18: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + old_locations_checked = ctx.locations_checked.copy() + + for container in range(256): + if get_flag(game_flags, (0x20 * 8) + container): + ctx.locations_checked.add(offset["Chest"] + container) + + for location, data in NPC_CHECKS.items(): + if get_flag(game_flags, data[0]) is data[1]: + ctx.locations_checked.add(location) + + for battlefield in range(20): + if battlefield_data[battlefield] == 0: + ctx.locations_checked.add(offset["BattlefieldItem"] + battlefield + 1) + + if old_locations_checked != ctx.locations_checked: + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": ctx.locations_checked}]) + + if received[0] == 0: + received_index = int.from_bytes(received[1:], "big") + if received_index < len(ctx.items_received): + item = ctx.items_received[received_index] + received_index += 1 + code = (item.item - ITEM_CODE_START) + 1 + if code > 256: + code -= 256 + snes_buffered_write(ctx, RECEIVED_DATA[0], bytes([code, *received_index.to_bytes(2, "big")])) + await snes_flush_writes(ctx) diff --git a/worlds/ffmq/Items.py b/worlds/ffmq/Items.py new file mode 100644 index 0000000000..3eab5dd532 --- /dev/null +++ b/worlds/ffmq/Items.py @@ -0,0 +1,298 @@ +from BaseClasses import ItemClassification, Item + +fillers = {"Cure Potion": 61, "Heal Potion": 52, "Refresher": 17, "Seed": 2, "Bomb Refill": 19, + "Projectile Refill": 50} + + +class ItemData: + def __init__(self, item_id, classification, groups=(), data_name=None): + self.groups = groups + self.classification = classification + self.id = None + if item_id is not None: + self.id = item_id + 0x420000 + self.data_name = data_name + + +item_table = { + "Elixir": ItemData(0, ItemClassification.progression, ["Key Items"]), + "Tree Wither": ItemData(1, ItemClassification.progression, ["Key Items"]), + "Wakewater": ItemData(2, ItemClassification.progression, ["Key Items"]), + "Venus Key": ItemData(3, ItemClassification.progression, ["Key Items"]), + "Multi Key": ItemData(4, ItemClassification.progression, ["Key Items"]), + "Mask": ItemData(5, ItemClassification.progression, ["Key Items"]), + "Magic Mirror": ItemData(6, ItemClassification.progression, ["Key Items"]), + "Thunder Rock": ItemData(7, ItemClassification.progression, ["Key Items"]), + "Captain's Cap": ItemData(8, ItemClassification.progression_skip_balancing, ["Key Items"]), + "Libra Crest": ItemData(9, ItemClassification.progression, ["Key Items"]), + "Gemini Crest": ItemData(10, ItemClassification.progression, ["Key Items"]), + "Mobius Crest": ItemData(11, ItemClassification.progression, ["Key Items"]), + "Sand Coin": ItemData(12, ItemClassification.progression, ["Key Items", "Coins"]), + "River Coin": ItemData(13, ItemClassification.progression, ["Key Items", "Coins"]), + "Sun Coin": ItemData(14, ItemClassification.progression, ["Key Items", "Coins"]), + "Sky Coin": ItemData(15, ItemClassification.progression_skip_balancing, ["Key Items", "Coins"]), + "Sky Fragment": ItemData(15 + 256, ItemClassification.progression_skip_balancing, ["Key Items"]), + "Cure Potion": ItemData(16, ItemClassification.filler, ["Consumables"]), + "Heal Potion": ItemData(17, ItemClassification.filler, ["Consumables"]), + "Seed": ItemData(18, ItemClassification.filler, ["Consumables"]), + "Refresher": ItemData(19, ItemClassification.filler, ["Consumables"]), + "Exit Book": ItemData(20, ItemClassification.useful, ["Spells"]), + "Cure Book": ItemData(21, ItemClassification.useful, ["Spells"]), + "Heal Book": ItemData(22, ItemClassification.useful, ["Spells"]), + "Life Book": ItemData(23, ItemClassification.useful, ["Spells"]), + "Quake Book": ItemData(24, ItemClassification.useful, ["Spells"]), + "Blizzard Book": ItemData(25, ItemClassification.useful, ["Spells"]), + "Fire Book": ItemData(26, ItemClassification.useful, ["Spells"]), + "Aero Book": ItemData(27, ItemClassification.useful, ["Spells"]), + "Thunder Seal": ItemData(28, ItemClassification.useful, ["Spells"]), + "White Seal": ItemData(29, ItemClassification.useful, ["Spells"]), + "Meteor Seal": ItemData(30, ItemClassification.useful, ["Spells"]), + "Flare Seal": ItemData(31, ItemClassification.useful, ["Spells"]), + "Progressive Sword": ItemData(32 + 256, ItemClassification.progression, ["Weapons", "Swords"]), + "Steel Sword": ItemData(32, ItemClassification.progression, ["Weapons", "Swords"]), + "Knight Sword": ItemData(33, ItemClassification.progression_skip_balancing, ["Weapons", "Swords"]), + "Excalibur": ItemData(34, ItemClassification.progression_skip_balancing, ["Weapons", "Swords"]), + "Progressive Axe": ItemData(35 + 256, ItemClassification.progression, ["Weapons", "Axes"]), + "Axe": ItemData(35, ItemClassification.progression, ["Weapons", "Axes"]), + "Battle Axe": ItemData(36, ItemClassification.progression_skip_balancing, ["Weapons", "Axes"]), + "Giant's Axe": ItemData(37, ItemClassification.progression_skip_balancing, ["Weapons", "Axes"]), + "Progressive Claw": ItemData(38 + 256, ItemClassification.progression, ["Weapons", "Axes"]), + "Cat Claw": ItemData(38, ItemClassification.progression, ["Weapons", "Claws"]), + "Charm Claw": ItemData(39, ItemClassification.progression_skip_balancing, ["Weapons", "Claws"]), + "Dragon Claw": ItemData(40, ItemClassification.progression, ["Weapons", "Claws"]), + "Progressive Bomb": ItemData(41 + 256, ItemClassification.progression, ["Weapons", "Bombs"]), + "Bomb": ItemData(41, ItemClassification.progression, ["Weapons", "Bombs"]), + "Jumbo Bomb": ItemData(42, ItemClassification.progression_skip_balancing, ["Weapons", "Bombs"]), + "Mega Grenade": ItemData(43, ItemClassification.progression, ["Weapons", "Bombs"]), + # Ally-only equipment does nothing when received, no reason to put them in the datapackage + #"Morning Star": ItemData(44, ItemClassification.progression, ["Weapons"]), + #"Bow Of Grace": ItemData(45, ItemClassification.progression, ["Weapons"]), + #"Ninja Star": ItemData(46, ItemClassification.progression, ["Weapons"]), + + "Progressive Helm": ItemData(47 + 256, ItemClassification.useful, ["Helms"]), + "Steel Helm": ItemData(47, ItemClassification.useful, ["Helms"]), + "Moon Helm": ItemData(48, ItemClassification.useful, ["Helms"]), + "Apollo Helm": ItemData(49, ItemClassification.useful, ["Helms"]), + "Progressive Armor": ItemData(50 + 256, ItemClassification.useful, ["Armors"]), + "Steel Armor": ItemData(50, ItemClassification.useful, ["Armors"]), + "Noble Armor": ItemData(51, ItemClassification.useful, ["Armors"]), + "Gaia's Armor": ItemData(52, ItemClassification.useful, ["Armors"]), + #"Replica Armor": ItemData(53, ItemClassification.progression, ["Armors"]), + #"Mystic Robes": ItemData(54, ItemClassification.progression, ["Armors"]), + #"Flame Armor": ItemData(55, ItemClassification.progression, ["Armors"]), + #"Black Robe": ItemData(56, ItemClassification.progression, ["Armors"]), + "Progressive Shield": ItemData(57 + 256, ItemClassification.useful, ["Shields"]), + "Steel Shield": ItemData(57, ItemClassification.useful, ["Shields"]), + "Venus Shield": ItemData(58, ItemClassification.useful, ["Shields"]), + "Aegis Shield": ItemData(59, ItemClassification.useful, ["Shields"]), + #"Ether Shield": ItemData(60, ItemClassification.progression, ["Shields"]), + "Progressive Accessory": ItemData(61 + 256, ItemClassification.useful, ["Accessories"]), + "Charm": ItemData(61, ItemClassification.useful, ["Accessories"]), + "Magic Ring": ItemData(62, ItemClassification.useful, ["Accessories"]), + "Cupid Locket": ItemData(63, ItemClassification.useful, ["Accessories"]), + + # these are understood by FFMQR and I could place these if I want, but it's easier to just let FFMQR + # place them. I want an option to make shuffle battlefield rewards NOT color-code the battlefields, + # and then I would make the non-item reward battlefields into AP checks and these would be put into those as + # the item for AP. But there is no such option right now. + # "54 XP": ItemData(96, ItemClassification.filler, data_name="Xp54"), + # "99 XP": ItemData(97, ItemClassification.filler, data_name="Xp99"), + # "540 XP": ItemData(98, ItemClassification.filler, data_name="Xp540"), + # "744 XP": ItemData(99, ItemClassification.filler, data_name="Xp744"), + # "816 XP": ItemData(100, ItemClassification.filler, data_name="Xp816"), + # "1068 XP": ItemData(101, ItemClassification.filler, data_name="Xp1068"), + # "1200 XP": ItemData(102, ItemClassification.filler, data_name="Xp1200"), + # "2700 XP": ItemData(103, ItemClassification.filler, data_name="Xp2700"), + # "2808 XP": ItemData(104, ItemClassification.filler, data_name="Xp2808"), + # "150 Gp": ItemData(105, ItemClassification.filler, data_name="Gp150"), + # "300 Gp": ItemData(106, ItemClassification.filler, data_name="Gp300"), + # "600 Gp": ItemData(107, ItemClassification.filler, data_name="Gp600"), + # "900 Gp": ItemData(108, ItemClassification.filler, data_name="Gp900"), + # "1200 Gp": ItemData(109, ItemClassification.filler, data_name="Gp1200"), + + + "Bomb Refill": ItemData(221, ItemClassification.filler, ["Refills"]), + "Projectile Refill": ItemData(222, ItemClassification.filler, ["Refills"]), + #"None": ItemData(255, ItemClassification.progression, []), + + "Kaeli 1": ItemData(None, ItemClassification.progression), + "Kaeli 2": ItemData(None, ItemClassification.progression), + "Tristam": ItemData(None, ItemClassification.progression), + "Phoebe 1": ItemData(None, ItemClassification.progression), + "Reuben 1": ItemData(None, ItemClassification.progression), + "Reuben Dad Saved": ItemData(None, ItemClassification.progression), + "Otto": ItemData(None, ItemClassification.progression), + "Captain Mac": ItemData(None, ItemClassification.progression), + "Ship Steering Wheel": ItemData(None, ItemClassification.progression), + "Minotaur": ItemData(None, ItemClassification.progression), + "Flamerus Rex": ItemData(None, ItemClassification.progression), + "Phanquid": ItemData(None, ItemClassification.progression), + "Freezer Crab": ItemData(None, ItemClassification.progression), + "Ice Golem": ItemData(None, ItemClassification.progression), + "Jinn": ItemData(None, ItemClassification.progression), + "Medusa": ItemData(None, ItemClassification.progression), + "Dualhead Hydra": ItemData(None, ItemClassification.progression), + "Gidrah": ItemData(None, ItemClassification.progression), + "Dullahan": ItemData(None, ItemClassification.progression), + "Pazuzu": ItemData(None, ItemClassification.progression), + "Aquaria Plaza": ItemData(None, ItemClassification.progression), + "Summer Aquaria": ItemData(None, ItemClassification.progression), + "Reuben Mine": ItemData(None, ItemClassification.progression), + "Alive Forest": ItemData(None, ItemClassification.progression), + "Rainbow Bridge": ItemData(None, ItemClassification.progression), + "Collapse Spencer's Cave": ItemData(None, ItemClassification.progression), + "Ship Liberated": ItemData(None, ItemClassification.progression), + "Ship Loaned": ItemData(None, ItemClassification.progression), + "Ship Dock Access": ItemData(None, ItemClassification.progression), + "Stone Golem": ItemData(None, ItemClassification.progression), + "Twinhead Wyvern": ItemData(None, ItemClassification.progression), + "Zuh": ItemData(None, ItemClassification.progression), + + "Libra Temple Crest Tile": ItemData(None, ItemClassification.progression), + "Life Temple Crest Tile": ItemData(None, ItemClassification.progression), + "Aquaria Vendor Crest Tile": ItemData(None, ItemClassification.progression), + "Fireburg Vendor Crest Tile": ItemData(None, ItemClassification.progression), + "Fireburg Grenademan Crest Tile": ItemData(None, ItemClassification.progression), + "Sealed Temple Crest Tile": ItemData(None, ItemClassification.progression), + "Wintry Temple Crest Tile": ItemData(None, ItemClassification.progression), + "Kaidge Temple Crest Tile": ItemData(None, ItemClassification.progression), + "Light Temple Crest Tile": ItemData(None, ItemClassification.progression), + "Windia Kids Crest Tile": ItemData(None, ItemClassification.progression), + "Windia Dock Crest Tile": ItemData(None, ItemClassification.progression), + "Ship Dock Crest Tile": ItemData(None, ItemClassification.progression), + "Alive Forest Libra Crest Tile": ItemData(None, ItemClassification.progression), + "Alive Forest Gemini Crest Tile": ItemData(None, ItemClassification.progression), + "Alive Forest Mobius Crest Tile": ItemData(None, ItemClassification.progression), + "Wood House Libra Crest Tile": ItemData(None, ItemClassification.progression), + "Wood House Gemini Crest Tile": ItemData(None, ItemClassification.progression), + "Wood House Mobius Crest Tile": ItemData(None, ItemClassification.progression), + "Barrel Pushed": ItemData(None, ItemClassification.progression), + "Long Spine Bombed": ItemData(None, ItemClassification.progression), + "Short Spine Bombed": ItemData(None, ItemClassification.progression), + "Skull 1 Bombed": ItemData(None, ItemClassification.progression), + "Skull 2 Bombed": ItemData(None, ItemClassification.progression), + "Ice Pyramid 1F Statue": ItemData(None, ItemClassification.progression), + "Ice Pyramid 3F Statue": ItemData(None, ItemClassification.progression), + "Ice Pyramid 4F Statue": ItemData(None, ItemClassification.progression), + "Ice Pyramid 5F Statue": ItemData(None, ItemClassification.progression), + "Spencer Cave Libra Block Bombed": ItemData(None, ItemClassification.progression), + "Lava Dome Plate": ItemData(None, ItemClassification.progression), + "Pazuzu 2F Lock": ItemData(None, ItemClassification.progression), + "Pazuzu 4F Lock": ItemData(None, ItemClassification.progression), + "Pazuzu 6F Lock": ItemData(None, ItemClassification.progression), + "Pazuzu 1F": ItemData(None, ItemClassification.progression), + "Pazuzu 2F": ItemData(None, ItemClassification.progression), + "Pazuzu 3F": ItemData(None, ItemClassification.progression), + "Pazuzu 4F": ItemData(None, ItemClassification.progression), + "Pazuzu 5F": ItemData(None, ItemClassification.progression), + "Pazuzu 6F": ItemData(None, ItemClassification.progression), + "Dark King": ItemData(None, ItemClassification.progression), + "Tristam Bone Item Given": ItemData(None, ItemClassification.progression), + #"Barred": ItemData(None, ItemClassification.progression), + +} + +prog_map = { + "Swords": "Progressive Sword", + "Axes": "Progressive Axe", + "Claws": "Progressive Claw", + "Bombs": "Progressive Bomb", + "Shields": "Progressive Shield", + "Armors": "Progressive Armor", + "Helms": "Progressive Helm", + "Accessories": "Progressive Accessory", +} + + +def yaml_item(text): + if text == "CaptainCap": + return "Captain's Cap" + elif text == "WakeWater": + return "Wakewater" + return "".join( + [(" " + c if (c.isupper() or c.isnumeric()) and not (text[i - 1].isnumeric() and c == "F") else c) for + i, c in enumerate(text)]).strip() + + +item_groups = {} +for item, data in item_table.items(): + for group in data.groups: + item_groups[group] = item_groups.get(group, []) + [item] + + +def create_items(self) -> None: + items = [] + starting_weapon = self.multiworld.starting_weapon[self.player].current_key.title().replace("_", " ") + if self.multiworld.progressive_gear[self.player]: + for item_group in prog_map: + if starting_weapon in self.item_name_groups[item_group]: + starting_weapon = prog_map[item_group] + break + self.multiworld.push_precollected(self.create_item(starting_weapon)) + self.multiworld.push_precollected(self.create_item("Steel Armor")) + if self.multiworld.sky_coin_mode[self.player] == "start_with": + self.multiworld.push_precollected(self.create_item("Sky Coin")) + + precollected_item_names = {item.name for item in self.multiworld.precollected_items[self.player]} + + def add_item(item_name): + if item_name in ["Steel Armor", "Sky Fragment"] or "Progressive" in item_name: + return + if item_name.lower().replace(" ", "_") == self.multiworld.starting_weapon[self.player].current_key: + return + if self.multiworld.progressive_gear[self.player]: + for item_group in prog_map: + if item_name in self.item_name_groups[item_group]: + item_name = prog_map[item_group] + break + if item_name == "Sky Coin": + if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": + for _ in range(40): + items.append(self.create_item("Sky Fragment")) + return + elif self.multiworld.sky_coin_mode[self.player] == "save_the_crystals": + items.append(self.create_filler()) + return + if item_name in precollected_item_names: + items.append(self.create_filler()) + return + i = self.create_item(item_name) + if self.multiworld.logic[self.player] != "friendly" and item_name in ("Magic Mirror", "Mask"): + i.classification = ItemClassification.useful + if (self.multiworld.logic[self.player] == "expert" and self.multiworld.map_shuffle[self.player] == "none" and + item_name == "Exit Book"): + i.classification = ItemClassification.progression + items.append(i) + + for item_group in ("Key Items", "Spells", "Armors", "Helms", "Shields", "Accessories", "Weapons"): + for item in self.item_name_groups[item_group]: + add_item(item) + + if self.multiworld.brown_boxes[self.player] == "include": + filler_items = [] + for item, count in fillers.items(): + filler_items += [self.create_item(item) for _ in range(count)] + if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": + self.multiworld.random.shuffle(filler_items) + filler_items = filler_items[39:] + items += filler_items + + self.multiworld.itempool += items + + if len(self.multiworld.player_ids) > 1: + early_choices = ["Sand Coin", "River Coin"] + early_item = self.multiworld.random.choice(early_choices) + self.multiworld.early_items[self.player][early_item] = 1 + + +class FFMQItem(Item): + game = "Final Fantasy Mystic Quest" + type = None + + def __init__(self, name, player: int = None): + item_data = item_table[name] + super(FFMQItem, self).__init__( + name, + item_data.classification, + item_data.id, player + ) \ No newline at end of file diff --git a/worlds/ffmq/LICENSE b/worlds/ffmq/LICENSE new file mode 100644 index 0000000000..46ad1c0074 --- /dev/null +++ b/worlds/ffmq/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Alex "Alchav" Avery +Copyright (c) 2023 wildham + +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. diff --git a/worlds/ffmq/Options.py b/worlds/ffmq/Options.py new file mode 100644 index 0000000000..4b9f4a4a88 --- /dev/null +++ b/worlds/ffmq/Options.py @@ -0,0 +1,356 @@ +from Options import Choice, FreeText, Toggle, Range + + +class Logic(Choice): + """Placement logic sets the rules that will be applied when placing items. Friendly: Required Items to clear a + dungeon will never be placed in that dungeon to avoid the need to revisit it. Also, the Magic Mirror and the Mask + will always be available before Ice Pyramid and Volcano, respectively. Note: If Dungeons are shuffled, Friendly + logic will only ensure the availability of the Mirror and the Mask. Standard: Items are randomly placed and logic + merely verifies that they're all accessible. As for Region access, only the Coins are considered. Expert: Same as + Standard, but Items Placement logic also includes other routes than Coins: the Crests Teleporters, the + Fireburg-Aquaria Lava bridge and the Sealed Temple Exit trick.""" + option_friendly = 0 + option_standard = 1 + option_expert = 2 + default = 1 + display_name = "Logic" + + +class BrownBoxes(Choice): + """Include the 201 brown box locations from the original game. Brown Boxes are all the boxes that contained a + consumable in the original game. If shuffle is chosen, the consumables contained will be shuffled but the brown + boxes will not be Archipelago location checks.""" + option_exclude = 0 + option_include = 1 + option_shuffle = 2 + default = 1 + display_name = "Brown Boxes" + + +class SkyCoinMode(Choice): + """Configure how the Sky Coin is acquired. With standard, the Sky Coin will be placed randomly. With Start With, the + Sky Coin will be in your inventory at the start of the game. With Save The Crystals, the Sky Coin will be acquired + once you save all 4 crystals. With Shattered Sky Coin, the Sky Coin is split in 40 fragments; you can enter Doom + Castle once the required amount is found. Shattered Sky Coin will force brown box locations to be included.""" + option_standard = 0 + option_start_with = 1 + option_save_the_crystals = 2 + option_shattered_sky_coin = 3 + default = 0 + display_name = "Sky Coin Mode" + + +class ShatteredSkyCoinQuantity(Choice): + """Configure the number of the 40 Sky Coin Fragments required to enter the Doom Castle. Only has an effect if + Sky Coin Mode is set to shattered. Low: 16. Mid: 24. High: 32. Random Narrow: random between 16 and 32. + Random Wide: random between 10 and 38.""" + option_low_16 = 0 + option_mid_24 = 1 + option_high_32 = 2 + option_random_narrow = 3 + option_random_wide = 4 + default = 1 + display_name = "Shattered Sky Coin" + + +class StartingWeapon(Choice): + """Choose your starting weapon.""" + display_name = "Starting Weapon" + option_steel_sword = 0 + option_axe = 1 + option_cat_claw = 2 + option_bomb = 3 + default = "random" + + +class ProgressiveGear(Toggle): + """Pieces of gear are always acquired from weakest to strongest in a set.""" + display_name = "Progressive Gear" + + +class EnemiesDensity(Choice): + """Set how many of the original enemies are on each map.""" + display_name = "Enemies Density" + option_all = 0 + option_three_quarter = 1 + option_half = 2 + option_quarter = 3 + option_none = 4 + + +class EnemyScaling(Choice): + """Superclass for enemy scaling options.""" + option_quarter = 0 + option_half = 1 + option_three_quarter = 2 + option_normal = 3 + option_one_and_quarter = 4 + option_one_and_half = 5 + option_double = 6 + option_double_and_half = 7 + option_triple = 8 + + +class EnemiesScalingLower(EnemyScaling): + """Randomly adjust enemies stats by the selected range percentage. Include mini-bosses' weaker clones.""" + display_name = "Enemies Scaling Lower" + default = 0 + + +class EnemiesScalingUpper(EnemyScaling): + """Randomly adjust enemies stats by the selected range percentage. Include mini-bosses' weaker clones.""" + display_name = "Enemies Scaling Upper" + default = 4 + + +class BossesScalingLower(EnemyScaling): + """Randomly adjust bosses stats by the selected range percentage. Include Mini-Bosses, Bosses, Bosses' refights and + the Dark King.""" + display_name = "Bosses Scaling Lower" + default = 0 + + +class BossesScalingUpper(EnemyScaling): + """Randomly adjust bosses stats by the selected range percentage. Include Mini-Bosses, Bosses, Bosses' refights and + the Dark King.""" + display_name = "Bosses Scaling Upper" + default = 4 + + +class EnemizerAttacks(Choice): + """Shuffles enemy attacks. Standard: No shuffle. Safe: Randomize every attack but leave out self-destruct and Dark + King attacks. Chaos: Randomize and include self-destruct and Dark King attacks. Self Destruct: Every enemy + self-destructs. Simple Shuffle: Instead of randomizing, shuffle one monster's attacks to another. Dark King is left + vanilla.""" + display_name = "Enemizer Attacks" + option_normal = 0 + option_safe = 1 + option_chaos = 2 + option_self_destruct = 3 + option_simple_shuffle = 4 + default = 0 + + +class EnemizerGroups(Choice): + """Set which enemy groups will be affected by Enemizer.""" + display_name = "Enemizer Groups" + option_mobs_only = 0 + option_mobs_and_bosses = 1 + option_mobs_bosses_and_dark_king = 2 + default = 1 + + +class ShuffleResWeakType(Toggle): + """Resistance and Weakness types are shuffled for all enemies.""" + display_name = "Shuffle Resistance/Weakness Types" + default = 0 + + +class ShuffleEnemiesPositions(Toggle): + """Instead of their original position in a given map, enemies are randomly placed.""" + display_name = "Shuffle Enemies' Positions" + default = 1 + + +class ProgressiveFormations(Choice): + """Enemies' formations are selected by regions, with the weakest formations always selected in Foresta and the + strongest in Windia. Disabled: Standard formations are used. Regions Strict: Formations will come exclusively + from the current region, whatever the map is. Regions Keep Type: Formations will keep the original formation type + and match with the nearest power level.""" + display_name = "Progressive Formations" + option_disabled = 0 + option_regions_strict = 1 + option_regions_keep_type = 2 + + +class DoomCastle(Choice): + """Configure how you reach the Dark King. With Standard, you need to defeat all four bosses and their floors to + reach the Dark King. With Boss Rush, only the bosses are blocking your way in the corridor to the Dark King's room. + With Dark King Only, the way to the Dark King is free of any obstacle.""" + display_name = "Doom Castle" + option_standard = 0 + option_boss_rush = 1 + option_dark_king_only = 2 + + +class DoomCastleShortcut(Toggle): + """Create a shortcut granting access from the start to Doom Castle at Focus Tower's entrance. + Also modify the Desert floor, so it can be navigated without the Mega Grenades and the Dragon Claw.""" + display_name = "Doom Castle Shortcut" + + +class TweakFrustratingDungeons(Toggle): + """Make some small changes to a few of the most annoying dungeons. Ice Pyramid: Add 3 shortcuts on the 1st floor. + Giant Tree: Add shortcuts on the 1st and 4th floors and curtail mushrooms population. + Pazuzu's Tower: Staircases are devoid of enemies (regardless of Enemies Density settings).""" + display_name = "Tweak Frustrating Dungeons" + + +class MapShuffle(Choice): + """None: No shuffle. Overworld: Only shuffle the Overworld locations. Dungeons: Only shuffle the dungeons' floors + amongst themselves. Temples and Towns aren't included. Overworld And Dungeons: Shuffle the Overworld and dungeons + at the same time. Everything: Shuffle the Overworld, dungeons, temples and towns all amongst each others. + When dungeons are shuffled, defeating Pazuzu won't teleport you to the 7th floor, you have to get there normally to + save the Crystal and get Pazuzu's Chest.""" + display_name = "Map Shuffle" + option_none = 0 + option_overworld = 1 + option_dungeons = 2 + option_overworld_and_dungeons = 3 + option_everything = 4 + default = 0 + + +class CrestShuffle(Toggle): + """Shuffle the Crest tiles amongst themselves.""" + display_name = "Crest Shuffle" + + +class MapShuffleSeed(FreeText): + """If this is a number, it will be used as a set seed number for Map, Crest, and Battlefield Reward shuffles. + If this is "random" the seed will be chosen randomly. If it is any other text, it will be used as a seed group name. + All players using the same seed group name will get the same shuffle results, as long as their Map Shuffle, + Crest Shuffle, and Shuffle Battlefield Rewards settings are the same.""" + display_name = "Map Shuffle Seed" + default = "random" + + +class LevelingCurve(Choice): + """Adjust the level gain rate.""" + display_name = "Leveling Curve" + option_half = 0 + option_normal = 1 + option_one_and_half = 2 + option_double = 3 + option_double_and_half = 4 + option_triple = 5 + option_quadruple = 6 + default = 4 + + +class ShuffleBattlefieldRewards(Toggle): + """Shuffle the type of reward (Item, XP, GP) given by battlefields and color code them by reward type. + Blue: Give an item. Grey: Give XP. Green: Give GP.""" + display_name = "Shuffle Battlefield Rewards" + + +class BattlefieldsBattlesQuantities(Choice): + """Adjust the number of battles that need to be fought to get a battlefield's reward.""" + display_name = "Battlefields Battles Quantity" + option_ten = 0 + option_seven = 1 + option_five = 2 + option_three = 3 + option_one = 4 + option_random_one_through_five = 5 + option_random_one_through_ten = 6 + + +class CompanionLevelingType(Choice): + """Set how companions gain levels. + Quests: Complete each companion's individual quest for them to promote to their second version. + Quests Extended: Each companion has four exclusive quests, leveling each time a quest is completed. + Save the Crystals (All): Each time a Crystal is saved, all companions gain levels. + Save the Crystals (Individual): Each companion will level to their second version when a specific Crystal is saved. + Benjamin Level: Companions' level tracks Benjamin's.""" + option_quests = 0 + option_quests_extended = 1 + option_save_crystals_individual = 2 + option_save_crystals_all = 3 + option_benjamin_level = 4 + option_benjamin_level_plus_5 = 5 + option_benjamin_level_plus_10 = 6 + default = 0 + display_name = "Companion Leveling Type" + + +class CompanionSpellbookType(Choice): + """Update companions' spellbook. + Standard: Original game spellbooks. + Extended: Add some extra spells. Tristam gains Exit and Quake and Reuben gets Blizzard. + Random Balanced: Randomize the spellbooks with an appropriate mix of spells. + Random Chaos: Randomize the spellbooks in total free-for-all.""" + option_standard = 0 + option_extended = 1 + option_random_balanced = 2 + option_random_chaos = 3 + default = 0 + display_name = "Companion Spellbook Type" + + +class StartingCompanion(Choice): + """Set a companion to start with. + Random Companion: Randomly select one companion. + Random Plus None: Randomly select a companion, with the possibility of none selected.""" + display_name = "Starting Companion" + default = 0 + option_none = 0 + option_kaeli = 1 + option_tristam = 2 + option_phoebe = 3 + option_reuben = 4 + option_random_companion = 5 + option_random_plus_none = 6 + + +class AvailableCompanions(Range): + """Select randomly which companions will join your party. Unavailable companions can still be reached to get their items and complete their quests if needed. + Note: If a Starting Companion is selected, it will always be available, regardless of this setting.""" + display_name = "Available Companions" + default = 4 + range_start = 0 + range_end = 4 + + +class CompanionsLocations(Choice): + """Set the primary location of companions. Their secondary location is always the same. + Standard: Companions will be at the same locations as in the original game. + Shuffled: Companions' locations are shuffled amongst themselves. + Shuffled Extended: Add all the Temples, as well as Phoebe's House and the Rope Bridge as possible locations.""" + display_name = "Companions' Locations" + default = 0 + option_standard = 0 + option_shuffled = 1 + option_shuffled_extended = 2 + + +class KaelisMomFightsMinotaur(Toggle): + """Transfer Kaeli's requirements (Tree Wither, Elixir) and the two items she's giving to her mom. + Kaeli will be available to join the party right away without the Tree Wither.""" + display_name = "Kaeli's Mom Fights Minotaur" + default = 0 + + +option_definitions = { + "logic": Logic, + "brown_boxes": BrownBoxes, + "sky_coin_mode": SkyCoinMode, + "shattered_sky_coin_quantity": ShatteredSkyCoinQuantity, + "starting_weapon": StartingWeapon, + "progressive_gear": ProgressiveGear, + "leveling_curve": LevelingCurve, + "starting_companion": StartingCompanion, + "available_companions": AvailableCompanions, + "companions_locations": CompanionsLocations, + "kaelis_mom_fight_minotaur": KaelisMomFightsMinotaur, + "companion_leveling_type": CompanionLevelingType, + "companion_spellbook_type": CompanionSpellbookType, + "enemies_density": EnemiesDensity, + "enemies_scaling_lower": EnemiesScalingLower, + "enemies_scaling_upper": EnemiesScalingUpper, + "bosses_scaling_lower": BossesScalingLower, + "bosses_scaling_upper": BossesScalingUpper, + "enemizer_attacks": EnemizerAttacks, + "enemizer_groups": EnemizerGroups, + "shuffle_res_weak_types": ShuffleResWeakType, + "shuffle_enemies_position": ShuffleEnemiesPositions, + "progressive_formations": ProgressiveFormations, + "doom_castle_mode": DoomCastle, + "doom_castle_shortcut": DoomCastleShortcut, + "tweak_frustrating_dungeons": TweakFrustratingDungeons, + "map_shuffle": MapShuffle, + "crest_shuffle": CrestShuffle, + "shuffle_battlefield_rewards": ShuffleBattlefieldRewards, + "map_shuffle_seed": MapShuffleSeed, + "battlefields_battles_quantities": BattlefieldsBattlesQuantities, +} diff --git a/worlds/ffmq/Output.py b/worlds/ffmq/Output.py new file mode 100644 index 0000000000..98ecd28986 --- /dev/null +++ b/worlds/ffmq/Output.py @@ -0,0 +1,125 @@ +import yaml +import os +import zipfile +from copy import deepcopy +from .Regions import object_id_table +from Main import __version__ +from worlds.Files import APContainer +import pkgutil + +settings_template = yaml.load(pkgutil.get_data(__name__, "data/settings.yaml"), yaml.Loader) + + +def generate_output(self, output_directory): + def output_item_name(item): + if item.player == self.player: + if item.code > 0x420000 + 256: + item_name = self.item_id_to_name[item.code - 256] + else: + item_name = item.name + item_name = "".join(item_name.split("'")) + item_name = "".join(item_name.split(" ")) + else: + if item.advancement or item.useful or (item.trap and + self.multiworld.per_slot_randoms[self.player].randint(0, 1)): + item_name = "APItem" + else: + item_name = "APItemFiller" + return item_name + + item_placement = [] + for location in self.multiworld.get_locations(self.player): + if location.type != "Trigger": + item_placement.append({"object_id": object_id_table[location.name], "type": location.type, "content": + output_item_name(location.item), "player": self.multiworld.player_name[location.item.player], + "item_name": location.item.name}) + + def cc(option): + return option.current_key.title().replace("_", "").replace("OverworldAndDungeons", + "OverworldDungeons").replace("MobsAndBosses", "MobsBosses").replace("MobsBossesAndDarkKing", + "MobsBossesDK").replace("BenjaminLevelPlus", "BenPlus").replace("BenjaminLevel", "BenPlus0").replace( + "RandomCompanion", "Random") + + def tf(option): + return True if option else False + + options = deepcopy(settings_template) + options["name"] = self.multiworld.player_name[self.player] + option_writes = { + "enemies_density": cc(self.multiworld.enemies_density[self.player]), + "chests_shuffle": "Include", + "shuffle_boxes_content": self.multiworld.brown_boxes[self.player] == "shuffle", + "npcs_shuffle": "Include", + "battlefields_shuffle": "Include", + "logic_options": cc(self.multiworld.logic[self.player]), + "shuffle_enemies_position": tf(self.multiworld.shuffle_enemies_position[self.player]), + "enemies_scaling_lower": cc(self.multiworld.enemies_scaling_lower[self.player]), + "enemies_scaling_upper": cc(self.multiworld.enemies_scaling_upper[self.player]), + "bosses_scaling_lower": cc(self.multiworld.bosses_scaling_lower[self.player]), + "bosses_scaling_upper": cc(self.multiworld.bosses_scaling_upper[self.player]), + "enemizer_attacks": cc(self.multiworld.enemizer_attacks[self.player]), + "leveling_curve": cc(self.multiworld.leveling_curve[self.player]), + "battles_quantity": cc(self.multiworld.battlefields_battles_quantities[self.player]) if + self.multiworld.battlefields_battles_quantities[self.player].value < 5 else + "RandomLow" if + self.multiworld.battlefields_battles_quantities[self.player].value == 5 else + "RandomHigh", + "shuffle_battlefield_rewards": tf(self.multiworld.shuffle_battlefield_rewards[self.player]), + "random_starting_weapon": True, + "progressive_gear": tf(self.multiworld.progressive_gear[self.player]), + "tweaked_dungeons": tf(self.multiworld.tweak_frustrating_dungeons[self.player]), + "doom_castle_mode": cc(self.multiworld.doom_castle_mode[self.player]), + "doom_castle_shortcut": tf(self.multiworld.doom_castle_shortcut[self.player]), + "sky_coin_mode": cc(self.multiworld.sky_coin_mode[self.player]), + "sky_coin_fragments_qty": cc(self.multiworld.shattered_sky_coin_quantity[self.player]), + "enable_spoilers": False, + "progressive_formations": cc(self.multiworld.progressive_formations[self.player]), + "map_shuffling": cc(self.multiworld.map_shuffle[self.player]), + "crest_shuffle": tf(self.multiworld.crest_shuffle[self.player]), + "enemizer_groups": cc(self.multiworld.enemizer_groups[self.player]), + "shuffle_res_weak_type": tf(self.multiworld.shuffle_res_weak_types[self.player]), + "companion_leveling_type": cc(self.multiworld.companion_leveling_type[self.player]), + "companion_spellbook_type": cc(self.multiworld.companion_spellbook_type[self.player]), + "starting_companion": cc(self.multiworld.starting_companion[self.player]), + "available_companions": ["Zero", "One", "Two", + "Three", "Four"][self.multiworld.available_companions[self.player].value], + "companions_locations": cc(self.multiworld.companions_locations[self.player]), + "kaelis_mom_fight_minotaur": tf(self.multiworld.kaelis_mom_fight_minotaur[self.player]), + } + + for option, data in option_writes.items(): + options["Final Fantasy Mystic Quest"][option][data] = 1 + + rom_name = f'MQ{__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed_name:11}'[:21] + self.rom_name = bytearray(rom_name, + 'utf8') + self.rom_name_available_event.set() + + setup = {"version": "1.5", "name": self.multiworld.player_name[self.player], "romname": rom_name, "seed": + hex(self.multiworld.per_slot_randoms[self.player].randint(0, 0xFFFFFFFF)).split("0x")[1].upper()} + + starting_items = [output_item_name(item) for item in self.multiworld.precollected_items[self.player]] + if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": + starting_items.append("SkyCoin") + + file_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.apmq") + + APMQ = APMQFile(file_path, player=self.player, player_name=self.multiworld.player_name[self.player]) + with zipfile.ZipFile(file_path, mode="w", compression=zipfile.ZIP_DEFLATED, + compresslevel=9) as zf: + zf.writestr("itemplacement.yaml", yaml.dump(item_placement)) + zf.writestr("flagset.yaml", yaml.dump(options)) + zf.writestr("startingitems.yaml", yaml.dump(starting_items)) + zf.writestr("setup.yaml", yaml.dump(setup)) + zf.writestr("rooms.yaml", yaml.dump(self.rooms)) + + APMQ.write_contents(zf) + + +class APMQFile(APContainer): + game = "Final Fantasy Mystic Quest" + + def get_manifest(self): + manifest = super().get_manifest() + manifest["patch_file_ending"] = ".apmq" + return manifest \ No newline at end of file diff --git a/worlds/ffmq/Regions.py b/worlds/ffmq/Regions.py new file mode 100644 index 0000000000..61f70864c0 --- /dev/null +++ b/worlds/ffmq/Regions.py @@ -0,0 +1,251 @@ +from BaseClasses import Region, MultiWorld, Entrance, Location, LocationProgressType, ItemClassification +from worlds.generic.Rules import add_rule +from .Items import item_groups, yaml_item +import pkgutil +import yaml + +rooms = yaml.load(pkgutil.get_data(__name__, "data/rooms.yaml"), yaml.Loader) +entrance_names = {entrance["id"]: entrance["name"] for entrance in yaml.load(pkgutil.get_data(__name__, "data/entrances.yaml"), yaml.Loader)} + +object_id_table = {} +object_type_table = {} +offset = {"Chest": 0x420000, "Box": 0x420000, "NPC": 0x420000 + 300, "BattlefieldItem": 0x420000 + 350} +for room in rooms: + for object in room["game_objects"]: + if "Hero Chest" in object["name"] or object["type"] == "Trigger": + continue + if object["type"] in ("BattlefieldItem", "BattlefieldXp", "BattlefieldGp"): + object_type_table[object["name"]] = "BattlefieldItem" + elif object["type"] in ("Chest", "NPC", "Box"): + object_type_table[object["name"]] = object["type"] + object_id_table[object["name"]] = object["object_id"] + +location_table = {loc_name: offset[object_type_table[loc_name]] + obj_id for loc_name, obj_id in + object_id_table.items()} + +weapons = ("Claw", "Bomb", "Sword", "Axe") +crest_warps = [51, 52, 53, 76, 96, 108, 158, 171, 175, 191, 275, 276, 277, 308, 334, 336, 396, 397] + + +def process_rules(spot, access): + for weapon in weapons: + if weapon in access: + add_rule(spot, lambda state, w=weapon: state.has_any(item_groups[w + "s"], spot.player)) + access = [yaml_item(rule) for rule in access if rule not in weapons] + add_rule(spot, lambda state: state.has_all(access, spot.player)) + + +def create_region(world: MultiWorld, player: int, name: str, room_id=None, locations=None, links=None): + if links is None: + links = [] + ret = Region(name, player, world) + if locations: + for location in locations: + location.parent_region = ret + ret.locations.append(location) + ret.links = links + ret.id = room_id + return ret + + +def get_entrance_to(entrance_to): + for room in rooms: + if room["id"] == entrance_to["target_room"]: + for link in room["links"]: + if link["target_room"] == entrance_to["room"]: + return link + else: + raise Exception(f"Did not find entrance {entrance_to}") + + +def create_regions(self): + + menu_region = create_region(self.multiworld, self.player, "Menu") + self.multiworld.regions.append(menu_region) + + for room in self.rooms: + self.multiworld.regions.append(create_region(self.multiworld, self.player, room["name"], room["id"], + [FFMQLocation(self.player, object["name"], location_table[object["name"]] if object["name"] in + location_table else None, object["type"], object["access"], + self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for object in + room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in ("BattlefieldGp", + "BattlefieldXp") and (object["type"] != "Box" or self.multiworld.brown_boxes[self.player] == "include") and + not (object["name"] == "Kaeli Companion" and not object["on_trigger"])], room["links"])) + + dark_king_room = self.multiworld.get_region("Doom Castle Dark King Room", self.player) + dark_king = FFMQLocation(self.player, "Dark King", None, "Trigger", []) + dark_king.parent_region = dark_king_room + dark_king.place_locked_item(self.create_item("Dark King")) + dark_king_room.locations.append(dark_king) + + connection = Entrance(self.player, f"Enter Overworld", menu_region) + connection.connect(self.multiworld.get_region("Overworld", self.player)) + menu_region.exits.append(connection) + + for region in self.multiworld.get_regions(self.player): + for link in region.links: + for connect_room in self.multiworld.get_regions(self.player): + if connect_room.id == link["target_room"]: + connection = Entrance(self.player, entrance_names[link["entrance"]] if "entrance" in link and + link["entrance"] != -1 else f"{region.name} to {connect_room.name}", region) + if "entrance" in link and link["entrance"] != -1: + spoiler = False + if link["entrance"] in crest_warps: + if self.multiworld.crest_shuffle[self.player]: + spoiler = True + elif self.multiworld.map_shuffle[self.player] == "everything": + spoiler = True + elif "Subregion" in region.name and self.multiworld.map_shuffle[self.player] not in ("dungeons", + "none"): + spoiler = True + elif "Subregion" not in region.name and self.multiworld.map_shuffle[self.player] not in ("none", + "overworld"): + spoiler = True + + if spoiler: + self.multiworld.spoiler.set_entrance(entrance_names[link["entrance"]], connect_room.name, + 'both', self.player) + if link["access"]: + process_rules(connection, link["access"]) + region.exits.append(connection) + connection.connect(connect_room) + break + +non_dead_end_crest_rooms = [ + 'Libra Temple', 'Aquaria Gemini Room', "GrenadeMan's Mobius Room", 'Fireburg Gemini Room', + 'Sealed Temple', 'Alive Forest', 'Kaidge Temple Upper Ledge', + 'Windia Kid House Basement', 'Windia Old People House Basement' +] + +non_dead_end_crest_warps = [ + 'Libra Temple - Libra Tile Script', 'Aquaria Gemini Room - Gemini Script', + 'GrenadeMan Mobius Room - Mobius Teleporter Script', 'Fireburg Gemini Room - Gemini Teleporter Script', + 'Sealed Temple - Gemini Tile Script', 'Alive Forest - Libra Teleporter Script', + 'Alive Forest - Gemini Teleporter Script', 'Alive Forest - Mobius Teleporter Script', + 'Kaidge Temple - Mobius Teleporter Script', 'Windia Kid House Basement - Mobius Teleporter', + 'Windia Old People House Basement - Mobius Teleporter Script', +] + + +vendor_locations = ["Aquaria - Vendor", "Fireburg - Vendor", "Windia - Vendor"] + + +def set_rules(self) -> None: + self.multiworld.completion_condition[self.player] = lambda state: state.has("Dark King", self.player) + + def hard_boss_logic(state): + return state.has_all(["River Coin", "Sand Coin"], self.player) + + add_rule(self.multiworld.get_location("Pazuzu 1F", self.player), hard_boss_logic) + add_rule(self.multiworld.get_location("Gidrah", self.player), hard_boss_logic) + add_rule(self.multiworld.get_location("Dullahan", self.player), hard_boss_logic) + + if self.multiworld.map_shuffle[self.player]: + for boss in ("Freezer Crab", "Ice Golem", "Jinn", "Medusa", "Dualhead Hydra"): + loc = self.multiworld.get_location(boss, self.player) + checked_regions = {loc.parent_region} + + def check_foresta(region): + if region.name == "Subregion Foresta": + add_rule(loc, hard_boss_logic) + return True + elif "Subregion" in region.name: + return True + for entrance in region.entrances: + if entrance.parent_region not in checked_regions: + checked_regions.add(entrance.parent_region) + if check_foresta(entrance.parent_region): + return True + check_foresta(loc.parent_region) + + if self.multiworld.logic[self.player] == "friendly": + process_rules(self.multiworld.get_entrance("Overworld - Ice Pyramid", self.player), + ["MagicMirror"]) + process_rules(self.multiworld.get_entrance("Overworld - Volcano", self.player), + ["Mask"]) + if self.multiworld.map_shuffle[self.player] in ("none", "overworld"): + process_rules(self.multiworld.get_entrance("Overworld - Bone Dungeon", self.player), + ["Bomb"]) + process_rules(self.multiworld.get_entrance("Overworld - Wintry Cave", self.player), + ["Bomb", "Claw"]) + process_rules(self.multiworld.get_entrance("Overworld - Ice Pyramid", self.player), + ["Bomb", "Claw"]) + process_rules(self.multiworld.get_entrance("Overworld - Mine", self.player), + ["MegaGrenade", "Claw", "Reuben1"]) + process_rules(self.multiworld.get_entrance("Overworld - Lava Dome", self.player), + ["MegaGrenade"]) + process_rules(self.multiworld.get_entrance("Overworld - Giant Tree", self.player), + ["DragonClaw", "Axe"]) + process_rules(self.multiworld.get_entrance("Overworld - Mount Gale", self.player), + ["DragonClaw"]) + process_rules(self.multiworld.get_entrance("Overworld - Pazuzu Tower", self.player), + ["DragonClaw", "Bomb"]) + process_rules(self.multiworld.get_entrance("Overworld - Mac Ship", self.player), + ["DragonClaw", "CaptainCap"]) + process_rules(self.multiworld.get_entrance("Overworld - Mac Ship Doom", self.player), + ["DragonClaw", "CaptainCap"]) + + if self.multiworld.logic[self.player] == "expert": + if self.multiworld.map_shuffle[self.player] == "none" and not self.multiworld.crest_shuffle[self.player]: + inner_room = self.multiworld.get_region("Wintry Temple Inner Room", self.player) + connection = Entrance(self.player, "Sealed Temple Exit Trick", inner_room) + connection.connect(self.multiworld.get_region("Wintry Temple Outer Room", self.player)) + connection.access_rule = lambda state: state.has("Exit Book", self.player) + inner_room.exits.append(connection) + else: + for crest_warp in non_dead_end_crest_warps: + entrance = self.multiworld.get_entrance(crest_warp, self.player) + if entrance.connected_region.name in non_dead_end_crest_rooms: + entrance.access_rule = lambda state: False + + if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": + logic_coins = [16, 24, 32, 32, 38][self.multiworld.shattered_sky_coin_quantity[self.player].value] + self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \ + lambda state: state.has("Sky Fragment", self.player, logic_coins) + elif self.multiworld.sky_coin_mode[self.player] == "save_the_crystals": + self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \ + lambda state: state.has_all(["Flamerus Rex", "Dualhead Hydra", "Ice Golem", "Pazuzu"], self.player) + elif self.multiworld.sky_coin_mode[self.player] in ("standard", "start_with"): + self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \ + lambda state: state.has("Sky Coin", self.player) + + +def stage_set_rules(multiworld): + # If there's no enemies, there's no repeatable income sources + no_enemies_players = [player for player in multiworld.get_game_players("Final Fantasy Mystic Quest") + if multiworld.enemies_density[player] == "none"] + if (len([item for item in multiworld.itempool if item.classification in (ItemClassification.filler, + ItemClassification.trap)]) > len([player for player in no_enemies_players if + multiworld.accessibility[player] == "minimal"]) * 3): + for player in no_enemies_players: + for location in vendor_locations: + if multiworld.accessibility[player] == "locations": + print("exclude") + multiworld.get_location(location, player).progress_type = LocationProgressType.EXCLUDED + else: + print("unreachable") + multiworld.get_location(location, player).access_rule = lambda state: False + else: + # There are not enough junk items to fill non-minimal players' vendors. Just set an item rule not allowing + # advancement items so that useful items can be placed. + print("no advancement") + for player in no_enemies_players: + for location in vendor_locations: + multiworld.get_location(location, player).item_rule = lambda item: not item.advancement + + + + +class FFMQLocation(Location): + game = "Final Fantasy Mystic Quest" + + def __init__(self, player, name, address, loc_type, access=None, event=None): + super(FFMQLocation, self).__init__( + player, name, + address + ) + self.type = loc_type + if access: + process_rules(self, access) + if event: + self.place_locked_item(event) diff --git a/worlds/ffmq/__init__.py b/worlds/ffmq/__init__.py new file mode 100644 index 0000000000..b995cc427c --- /dev/null +++ b/worlds/ffmq/__init__.py @@ -0,0 +1,219 @@ +import Utils +import settings +import base64 +import threading +import requests +import yaml +from worlds.AutoWorld import World, WebWorld +from BaseClasses import Tutorial +from .Regions import create_regions, location_table, set_rules, stage_set_rules, rooms, non_dead_end_crest_rooms,\ + non_dead_end_crest_warps +from .Items import item_table, item_groups, create_items, FFMQItem, fillers +from .Output import generate_output +from .Options import option_definitions +from .Client import FFMQClient + + +# removed until lists are supported +# class FFMQSettings(settings.Group): +# class APIUrls(list): +# """A list of API URLs to get map shuffle, crest shuffle, and battlefield reward shuffle data from.""" +# api_urls: APIUrls = [ +# "https://api.ffmqrando.net/", +# "http://ffmqr.jalchavware.com:5271/" +# ] + + +class FFMQWebWorld(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to playing Final Fantasy Mystic Quest with Archipelago.", + "English", + "setup_en.md", + "setup/en", + ["Alchav"] + )] + + +class FFMQWorld(World): + """Final Fantasy: Mystic Quest is a simple, humorous RPG for the Super Nintendo. You travel across four continents, + linked in the middle of the world by the Focus Tower, which has been locked by four magical coins. Make your way to + the bottom of the Focus Tower, then straight up through the top!""" + # -Giga Otomia + + game = "Final Fantasy Mystic Quest" + + item_name_to_id = {name: data.id for name, data in item_table.items() if data.id is not None} + location_name_to_id = location_table + option_definitions = option_definitions + + topology_present = True + + item_name_groups = item_groups + + generate_output = generate_output + create_items = create_items + create_regions = create_regions + set_rules = set_rules + stage_set_rules = stage_set_rules + + data_version = 1 + + web = FFMQWebWorld() + # settings: FFMQSettings + + def __init__(self, world, player: int): + self.rom_name_available_event = threading.Event() + self.rom_name = None + self.rooms = None + super().__init__(world, player) + + def generate_early(self): + if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": + self.multiworld.brown_boxes[self.player].value = 1 + if self.multiworld.enemies_scaling_lower[self.player].value > \ + self.multiworld.enemies_scaling_upper[self.player].value: + (self.multiworld.enemies_scaling_lower[self.player].value, + self.multiworld.enemies_scaling_upper[self.player].value) =\ + (self.multiworld.enemies_scaling_upper[self.player].value, + self.multiworld.enemies_scaling_lower[self.player].value) + if self.multiworld.bosses_scaling_lower[self.player].value > \ + self.multiworld.bosses_scaling_upper[self.player].value: + (self.multiworld.bosses_scaling_lower[self.player].value, + self.multiworld.bosses_scaling_upper[self.player].value) =\ + (self.multiworld.bosses_scaling_upper[self.player].value, + self.multiworld.bosses_scaling_lower[self.player].value) + + @classmethod + def stage_generate_early(cls, multiworld): + + # api_urls = Utils.get_options()["ffmq_options"].get("api_urls", None) + api_urls = [ + "https://api.ffmqrando.net/", + "http://ffmqr.jalchavware.com:5271/" + ] + + rooms_data = {} + + for world in multiworld.get_game_worlds("Final Fantasy Mystic Quest"): + if (world.multiworld.map_shuffle[world.player] or world.multiworld.crest_shuffle[world.player] or + world.multiworld.crest_shuffle[world.player]): + if world.multiworld.map_shuffle_seed[world.player].value.isdigit(): + multiworld.random.seed(int(world.multiworld.map_shuffle_seed[world.player].value)) + elif world.multiworld.map_shuffle_seed[world.player].value != "random": + multiworld.random.seed(int(hash(world.multiworld.map_shuffle_seed[world.player].value)) + + int(world.multiworld.seed)) + + seed = hex(multiworld.random.randint(0, 0xFFFFFFFF)).split("0x")[1].upper() + map_shuffle = multiworld.map_shuffle[world.player].value + crest_shuffle = multiworld.crest_shuffle[world.player].current_key + battlefield_shuffle = multiworld.shuffle_battlefield_rewards[world.player].current_key + companion_shuffle = multiworld.companions_locations[world.player].value + kaeli_mom = multiworld.kaelis_mom_fight_minotaur[world.player].current_key + + query = f"s={seed}&m={map_shuffle}&c={crest_shuffle}&b={battlefield_shuffle}&cs={companion_shuffle}&km={kaeli_mom}" + + if query in rooms_data: + world.rooms = rooms_data[query] + continue + + if not api_urls: + raise Exception("No FFMQR API URLs specified in host.yaml") + + errors = [] + for api_url in api_urls.copy(): + try: + response = requests.get(f"{api_url}GenerateRooms?{query}") + except (ConnectionError, requests.exceptions.HTTPError, requests.exceptions.ConnectionError, + requests.exceptions.RequestException) as err: + api_urls.remove(api_url) + errors.append([api_url, err]) + else: + if response.ok: + world.rooms = rooms_data[query] = yaml.load(response.text, yaml.Loader) + break + else: + api_urls.remove(api_url) + errors.append([api_url, response]) + else: + error_text = f"Failed to fetch map shuffle data for FFMQ player {world.player}" + for error in errors: + error_text += f"\n{error[0]} - got error {error[1].status_code} {error[1].reason} {error[1].text}" + raise Exception(error_text) + api_urls.append(api_urls.pop(0)) + else: + world.rooms = rooms + + def create_item(self, name: str): + return FFMQItem(name, self.player) + + def collect_item(self, state, item, remove=False): + if "Progressive" in item.name: + i = item.code - 256 + if state.has(self.item_id_to_name[i], self.player): + if state.has(self.item_id_to_name[i+1], self.player): + return self.item_id_to_name[i+2] + return self.item_id_to_name[i+1] + return self.item_id_to_name[i] + return item.name if item.advancement else None + + def modify_multidata(self, multidata): + # wait for self.rom_name to be available. + self.rom_name_available_event.wait() + rom_name = getattr(self, "rom_name", None) + # we skip in case of error, so that the original error in the output thread is the one that gets raised + if rom_name: + new_name = base64.b64encode(bytes(self.rom_name)).decode() + payload = multidata["connect_names"][self.multiworld.player_name[self.player]] + multidata["connect_names"][new_name] = payload + + def get_filler_item_name(self): + r = self.multiworld.random.randint(0, 201) + for item, count in fillers.items(): + r -= count + r -= fillers[item] + if r <= 0: + return item + + def extend_hint_information(self, hint_data): + hint_data[self.player] = {} + if self.multiworld.map_shuffle[self.player]: + single_location_regions = ["Subregion Volcano Battlefield", "Subregion Mac's Ship", "Subregion Doom Castle"] + for subregion in ["Subregion Foresta", "Subregion Aquaria", "Subregion Frozen Fields", "Subregion Fireburg", + "Subregion Volcano Battlefield", "Subregion Windia", "Subregion Mac's Ship", + "Subregion Doom Castle"]: + region = self.multiworld.get_region(subregion, self.player) + for location in region.locations: + if location.address and self.multiworld.map_shuffle[self.player] != "dungeons": + hint_data[self.player][location.address] = (subregion.split("Subregion ")[-1] + + (" Region" if subregion not in + single_location_regions else "")) + for overworld_spot in region.exits: + if ("Subregion" in overworld_spot.connected_region.name or + overworld_spot.name == "Overworld - Mac Ship Doom" or "Focus Tower" in overworld_spot.name + or "Doom Castle" in overworld_spot.name or overworld_spot.name == "Overworld - Giant Tree"): + continue + exits = list(overworld_spot.connected_region.exits) + [overworld_spot] + checked_regions = set() + while exits: + exit_check = exits.pop() + if (exit_check.connected_region not in checked_regions and "Subregion" not in + exit_check.connected_region.name): + checked_regions.add(exit_check.connected_region) + exits.extend(exit_check.connected_region.exits) + for location in exit_check.connected_region.locations: + if location.address: + hint = [] + if self.multiworld.map_shuffle[self.player] != "dungeons": + hint.append((subregion.split("Subregion ")[-1] + (" Region" if subregion not + in single_location_regions else ""))) + if self.multiworld.map_shuffle[self.player] != "overworld" and subregion not in \ + ("Subregion Mac's Ship", "Subregion Doom Castle"): + hint.append(overworld_spot.name.split("Overworld - ")[-1].replace("Pazuzu", + "Pazuzu's")) + hint = " - ".join(hint) + if location.address in hint_data[self.player]: + hint_data[self.player][location.address] += f"/{hint}" + else: + hint_data[self.player][location.address] = hint + diff --git a/worlds/ffmq/data/entrances.yaml b/worlds/ffmq/data/entrances.yaml new file mode 100644 index 0000000000..1dfef2655c --- /dev/null +++ b/worlds/ffmq/data/entrances.yaml @@ -0,0 +1,2450 @@ +- name: Doom Castle - Sand Floor - To Sky Door - Sand Floor + id: 0 + area: 7 + coordinates: [24, 19] + teleporter: [0, 0] +- name: Doom Castle - Sand Floor - Main Entrance - Sand Floor + id: 1 + area: 7 + coordinates: [19, 43] + teleporter: [1, 6] +- name: Doom Castle - Aero Room - Aero Room Entrance + id: 2 + area: 7 + coordinates: [27, 39] + teleporter: [1, 0] +- name: Focus Tower B1 - Main Loop - South Entrance + id: 3 + area: 8 + coordinates: [43, 60] + teleporter: [2, 6] +- name: Focus Tower B1 - Main Loop - To Focus Tower 1F - Main Hall + id: 4 + area: 8 + coordinates: [37, 41] + teleporter: [4, 0] +- name: Focus Tower B1 - Aero Corridor - To Focus Tower 1F - Sun Coin Room + id: 5 + area: 8 + coordinates: [59, 35] + teleporter: [5, 0] +- name: Focus Tower B1 - Aero Corridor - To Sand Floor - Aero Chest + id: 6 + area: 8 + coordinates: [57, 59] + teleporter: [8, 0] +- name: Focus Tower B1 - Inner Loop - To Focus Tower 1F - Sky Door + id: 7 + area: 8 + coordinates: [51, 49] + teleporter: [6, 0] +- name: Focus Tower B1 - Inner Loop - To Doom Castle Sand Floor + id: 8 + area: 8 + coordinates: [51, 45] + teleporter: [7, 0] +- name: Focus Tower 1F - Focus Tower West Entrance + id: 9 + area: 9 + coordinates: [25, 29] + teleporter: [3, 6] +- name: Focus Tower 1F - To Focus Tower 2F - From SandCoin + id: 10 + area: 9 + coordinates: [16, 4] + teleporter: [10, 0] +- name: Focus Tower 1F - To Focus Tower B1 - Main Hall + id: 11 + area: 9 + coordinates: [4, 23] + teleporter: [11, 0] +- name: Focus Tower 1F - To Focus Tower B1 - To Aero Chest + id: 12 + area: 9 + coordinates: [26, 17] + teleporter: [12, 0] +- name: Focus Tower 1F - Sky Door + id: 13 + area: 9 + coordinates: [16, 24] + teleporter: [13, 0] +- name: Focus Tower 1F - To Focus Tower 2F - From RiverCoin + id: 14 + area: 9 + coordinates: [16, 10] + teleporter: [14, 0] +- name: Focus Tower 1F - To Focus Tower B1 - From Sky Door + id: 15 + area: 9 + coordinates: [16, 29] + teleporter: [15, 0] +- name: Focus Tower 2F - Sand Coin Passage - North Entrance + id: 16 + area: 10 + coordinates: [49, 30] + teleporter: [4, 6] +- name: Focus Tower 2F - Sand Coin Passage - To Focus Tower 1F - To SandCoin + id: 17 + area: 10 + coordinates: [47, 33] + teleporter: [17, 0] +- name: Focus Tower 2F - River Coin Passage - To Focus Tower 1F - To RiverCoin + id: 18 + area: 10 + coordinates: [47, 41] + teleporter: [18, 0] +- name: Focus Tower 2F - River Coin Passage - To Focus Tower 3F - Lower Floor + id: 19 + area: 10 + coordinates: [38, 40] + teleporter: [20, 0] +- name: Focus Tower 2F - Venus Chest Room - To Focus Tower 3F - Upper Floor + id: 20 + area: 10 + coordinates: [56, 40] + teleporter: [19, 0] +- name: Focus Tower 2F - Venus Chest Room - Pillar Script + id: 21 + area: 10 + coordinates: [48, 53] + teleporter: [13, 8] +- name: Focus Tower 3F - Lower Floor - To Fireburg Entrance + id: 22 + area: 11 + coordinates: [11, 39] + teleporter: [6, 6] +- name: Focus Tower 3F - Lower Floor - To Focus Tower 2F - Jump on Pillar + id: 23 + area: 11 + coordinates: [6, 47] + teleporter: [24, 0] +- name: Focus Tower 3F - Upper Floor - To Aquaria Entrance + id: 24 + area: 11 + coordinates: [21, 38] + teleporter: [5, 6] +- name: Focus Tower 3F - Upper Floor - To Focus Tower 2F - Venus Chest Room + id: 25 + area: 11 + coordinates: [24, 47] + teleporter: [23, 0] +- name: Level Forest - Boulder Script + id: 26 + area: 14 + coordinates: [52, 15] + teleporter: [0, 8] +- name: Level Forest - Rotten Tree Script + id: 27 + area: 14 + coordinates: [47, 6] + teleporter: [2, 8] +- name: Level Forest - Exit Level Forest 1 + id: 28 + area: 14 + coordinates: [46, 25] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 2 + id: 29 + area: 14 + coordinates: [46, 26] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 3 + id: 30 + area: 14 + coordinates: [47, 25] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 4 + id: 31 + area: 14 + coordinates: [47, 26] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 5 + id: 32 + area: 14 + coordinates: [60, 14] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 6 + id: 33 + area: 14 + coordinates: [61, 14] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 7 + id: 34 + area: 14 + coordinates: [46, 4] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 8 + id: 35 + area: 14 + coordinates: [46, 3] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest 9 + id: 36 + area: 14 + coordinates: [47, 4] + teleporter: [25, 0] +- name: Level Forest - Exit Level Forest A + id: 37 + area: 14 + coordinates: [47, 3] + teleporter: [25, 0] +- name: Foresta - Exit Foresta 1 + id: 38 + area: 15 + coordinates: [10, 25] + teleporter: [31, 0] +- name: Foresta - Exit Foresta 2 + id: 39 + area: 15 + coordinates: [10, 26] + teleporter: [31, 0] +- name: Foresta - Exit Foresta 3 + id: 40 + area: 15 + coordinates: [11, 25] + teleporter: [31, 0] +- name: Foresta - Exit Foresta 4 + id: 41 + area: 15 + coordinates: [11, 26] + teleporter: [31, 0] +- name: Foresta - Old Man House - Front Door + id: 42 + area: 15 + coordinates: [25, 17] + teleporter: [32, 4] +- name: Foresta - Old Man House - Back Door + id: 43 + area: 15 + coordinates: [25, 14] + teleporter: [33, 0] +- name: Foresta - Kaeli's House + id: 44 + area: 15 + coordinates: [7, 21] + teleporter: [0, 5] +- name: Foresta - Rest House + id: 45 + area: 15 + coordinates: [23, 23] + teleporter: [1, 5] +- name: Kaeli's House - Kaeli's House Entrance + id: 46 + area: 16 + coordinates: [11, 20] + teleporter: [86, 3] +- name: Foresta Houses - Old Man's House - Old Man Front Exit + id: 47 + area: 17 + coordinates: [35, 44] + teleporter: [34, 0] +- name: Foresta Houses - Old Man's House - Old Man Back Exit + id: 48 + area: 17 + coordinates: [35, 27] + teleporter: [35, 0] +- name: Foresta - Old Man House - Barrel Tile Script # New, use the focus tower column's script + id: 483 + area: 17 + coordinates: [0x23, 0x1E] + teleporter: [0x0D, 8] +- name: Foresta Houses - Rest House - Bed Script + id: 49 + area: 17 + coordinates: [30, 6] + teleporter: [1, 8] +- name: Foresta Houses - Rest House - Rest House Exit + id: 50 + area: 17 + coordinates: [35, 20] + teleporter: [87, 3] +- name: Foresta Houses - Libra House - Libra House Script + id: 51 + area: 17 + coordinates: [8, 49] + teleporter: [67, 8] +- name: Foresta Houses - Gemini House - Gemini House Script + id: 52 + area: 17 + coordinates: [26, 55] + teleporter: [68, 8] +- name: Foresta Houses - Mobius House - Mobius House Script + id: 53 + area: 17 + coordinates: [14, 33] + teleporter: [69, 8] +- name: Sand Temple - Sand Temple Entrance + id: 54 + area: 18 + coordinates: [56, 27] + teleporter: [36, 0] +- name: Bone Dungeon 1F - Bone Dungeon Entrance + id: 55 + area: 19 + coordinates: [13, 60] + teleporter: [37, 0] +- name: Bone Dungeon 1F - To Bone Dungeon B1 + id: 56 + area: 19 + coordinates: [13, 39] + teleporter: [2, 2] +- name: Bone Dungeon B1 - Waterway - Exit Waterway + id: 57 + area: 20 + coordinates: [27, 39] + teleporter: [3, 2] +- name: Bone Dungeon B1 - Waterway - Tristam's Script + id: 58 + area: 20 + coordinates: [27, 45] + teleporter: [3, 8] +- name: Bone Dungeon B1 - Waterway - To Bone Dungeon 1F + id: 59 + area: 20 + coordinates: [54, 61] + teleporter: [88, 3] +- name: Bone Dungeon B1 - Checker Room - Exit Checker Room + id: 60 + area: 20 + coordinates: [23, 40] + teleporter: [4, 2] +- name: Bone Dungeon B1 - Checker Room - To Waterway + id: 61 + area: 20 + coordinates: [39, 49] + teleporter: [89, 3] +- name: Bone Dungeon B1 - Hidden Room - To B2 - Exploding Skull Room + id: 62 + area: 20 + coordinates: [5, 33] + teleporter: [91, 3] +- name: Bonne Dungeon B2 - Exploding Skull Room - To Hidden Passage + id: 63 + area: 21 + coordinates: [19, 13] + teleporter: [5, 2] +- name: Bonne Dungeon B2 - Exploding Skull Room - To Two Skulls Room + id: 64 + area: 21 + coordinates: [29, 15] + teleporter: [6, 2] +- name: Bonne Dungeon B2 - Exploding Skull Room - To Checker Room + id: 65 + area: 21 + coordinates: [8, 25] + teleporter: [90, 3] +- name: Bonne Dungeon B2 - Box Room - To B2 - Two Skulls Room + id: 66 + area: 21 + coordinates: [59, 12] + teleporter: [93, 3] +- name: Bonne Dungeon B2 - Quake Room - To B2 - Two Skulls Room + id: 67 + area: 21 + coordinates: [59, 28] + teleporter: [94, 3] +- name: Bonne Dungeon B2 - Two Skulls Room - To Box Room + id: 68 + area: 21 + coordinates: [53, 7] + teleporter: [7, 2] +- name: Bonne Dungeon B2 - Two Skulls Room - To Quake Room + id: 69 + area: 21 + coordinates: [41, 3] + teleporter: [8, 2] +- name: Bonne Dungeon B2 - Two Skulls Room - To Boss Room + id: 70 + area: 21 + coordinates: [47, 57] + teleporter: [9, 2] +- name: Bonne Dungeon B2 - Two Skulls Room - To B2 - Exploding Skull Room + id: 71 + area: 21 + coordinates: [54, 23] + teleporter: [92, 3] +- name: Bone Dungeon B2 - Boss Room - Flamerus Rex Script + id: 72 + area: 22 + coordinates: [29, 19] + teleporter: [4, 8] +- name: Bone Dungeon B2 - Boss Room - Tristam Leave Script + id: 73 + area: 22 + coordinates: [29, 23] + teleporter: [75, 8] +- name: Bone Dungeon B2 - Boss Room - To B2 - Two Skulls Room + id: 74 + area: 22 + coordinates: [30, 27] + teleporter: [95, 3] +- name: Libra Temple - Entrance + id: 75 + area: 23 + coordinates: [10, 15] + teleporter: [13, 6] +- name: Libra Temple - Libra Tile Script + id: 76 + area: 23 + coordinates: [9, 8] + teleporter: [59, 8] +- name: Aquaria Winter - Winter Entrance 1 + id: 77 + area: 24 + coordinates: [25, 25] + teleporter: [8, 6] +- name: Aquaria Winter - Winter Entrance 2 + id: 78 + area: 24 + coordinates: [25, 26] + teleporter: [8, 6] +- name: Aquaria Winter - Winter Entrance 3 + id: 79 + area: 24 + coordinates: [26, 25] + teleporter: [8, 6] +- name: Aquaria Winter - Winter Entrance 4 + id: 80 + area: 24 + coordinates: [26, 26] + teleporter: [8, 6] +- name: Aquaria Winter - Winter Phoebe's House Entrance Script #Modified to not be a script + id: 81 + area: 24 + coordinates: [8, 19] + teleporter: [10, 5] # original value [5, 8] +- name: Aquaria Winter - Winter Vendor House Entrance + id: 82 + area: 24 + coordinates: [8, 5] + teleporter: [44, 4] +- name: Aquaria Winter - Winter INN Entrance + id: 83 + area: 24 + coordinates: [26, 17] + teleporter: [11, 5] +- name: Aquaria Summer - Summer Entrance 1 + id: 84 + area: 25 + coordinates: [57, 25] + teleporter: [8, 6] +- name: Aquaria Summer - Summer Entrance 2 + id: 85 + area: 25 + coordinates: [57, 26] + teleporter: [8, 6] +- name: Aquaria Summer - Summer Entrance 3 + id: 86 + area: 25 + coordinates: [58, 25] + teleporter: [8, 6] +- name: Aquaria Summer - Summer Entrance 4 + id: 87 + area: 25 + coordinates: [58, 26] + teleporter: [8, 6] +- name: Aquaria Summer - Summer Phoebe's House Entrance + id: 88 + area: 25 + coordinates: [40, 19] + teleporter: [10, 5] +- name: Aquaria Summer - Spencer's Place Entrance Top + id: 89 + area: 25 + coordinates: [40, 16] + teleporter: [42, 0] +- name: Aquaria Summer - Spencer's Place Entrance Side + id: 90 + area: 25 + coordinates: [41, 18] + teleporter: [43, 0] +- name: Aquaria Summer - Summer Vendor House Entrance + id: 91 + area: 25 + coordinates: [40, 5] + teleporter: [44, 4] +- name: Aquaria Summer - Summer INN Entrance + id: 92 + area: 25 + coordinates: [58, 17] + teleporter: [11, 5] +- name: Phoebe's House - Entrance # Change to a script, same as vendor house + id: 93 + area: 26 + coordinates: [29, 14] + teleporter: [5, 8] # Original Value [11,3] +- name: Aquaria Vendor House - Vendor House Entrance's Script + id: 94 + area: 27 + coordinates: [7, 10] + teleporter: [40, 8] +- name: Aquaria Vendor House - Vendor House Stairs + id: 95 + area: 27 + coordinates: [1, 4] + teleporter: [47, 0] +- name: Aquaria Gemini Room - Gemini Script + id: 96 + area: 27 + coordinates: [2, 40] + teleporter: [72, 8] +- name: Aquaria Gemini Room - Gemini Room Stairs + id: 97 + area: 27 + coordinates: [4, 39] + teleporter: [48, 0] +- name: Aquaria INN - Aquaria INN entrance # Change to a script, same as vendor house + id: 98 + area: 27 + coordinates: [51, 46] + teleporter: [75, 8] # Original value [48,3] +- name: Wintry Cave 1F - Main Entrance + id: 99 + area: 28 + coordinates: [50, 58] + teleporter: [49, 0] +- name: Wintry Cave 1F - To 3F Top + id: 100 + area: 28 + coordinates: [40, 25] + teleporter: [14, 2] +- name: Wintry Cave 1F - To 2F + id: 101 + area: 28 + coordinates: [10, 43] + teleporter: [15, 2] +- name: Wintry Cave 1F - Phoebe's Script + id: 102 + area: 28 + coordinates: [44, 37] + teleporter: [6, 8] +- name: Wintry Cave 2F - To 3F Bottom + id: 103 + area: 29 + coordinates: [58, 5] + teleporter: [50, 0] +- name: Wintry Cave 2F - To 1F + id: 104 + area: 29 + coordinates: [38, 18] + teleporter: [97, 3] +- name: Wintry Cave 3F Top - Exit from 3F Top + id: 105 + area: 30 + coordinates: [24, 6] + teleporter: [96, 3] +- name: Wintry Cave 3F Bottom - Exit to 2F + id: 106 + area: 31 + coordinates: [4, 29] + teleporter: [51, 0] +- name: Life Temple - Entrance + id: 107 + area: 32 + coordinates: [9, 60] + teleporter: [14, 6] +- name: Life Temple - Libra Tile Script + id: 108 + area: 32 + coordinates: [3, 55] + teleporter: [60, 8] +- name: Life Temple - Mysterious Man Script + id: 109 + area: 32 + coordinates: [9, 44] + teleporter: [78, 8] +- name: Fall Basin - Back Exit Script + id: 110 + area: 33 + coordinates: [17, 5] + teleporter: [9, 0] # Remove script [42, 8] for overworld teleport (but not main exit) +- name: Fall Basin - Main Exit + id: 111 + area: 33 + coordinates: [15, 26] + teleporter: [53, 0] +- name: Fall Basin - Phoebe's Script + id: 112 + area: 33 + coordinates: [17, 6] + teleporter: [9, 8] +- name: Ice Pyramid B1 Taunt Room - To Climbing Wall Room + id: 113 + area: 34 + coordinates: [43, 6] + teleporter: [55, 0] +- name: Ice Pyramid 1F Maze - Main Entrance 1 + id: 114 + area: 35 + coordinates: [18, 36] + teleporter: [56, 0] +- name: Ice Pyramid 1F Maze - Main Entrance 2 + id: 115 + area: 35 + coordinates: [19, 36] + teleporter: [56, 0] +- name: Ice Pyramid 1F Maze - West Stairs To 2F South Tiled Room + id: 116 + area: 35 + coordinates: [3, 27] + teleporter: [57, 0] +- name: Ice Pyramid 1F Maze - West Center Stairs to 2F West Room + id: 117 + area: 35 + coordinates: [11, 15] + teleporter: [58, 0] +- name: Ice Pyramid 1F Maze - East Center Stairs to 2F Center Room + id: 118 + area: 35 + coordinates: [25, 16] + teleporter: [59, 0] +- name: Ice Pyramid 1F Maze - Upper Stairs to 2F Small North Room + id: 119 + area: 35 + coordinates: [31, 1] + teleporter: [60, 0] +- name: Ice Pyramid 1F Maze - East Stairs to 2F North Corridor + id: 120 + area: 35 + coordinates: [34, 9] + teleporter: [61, 0] +- name: Ice Pyramid 1F Maze - Statue's Script + id: 121 + area: 35 + coordinates: [21, 32] + teleporter: [77, 8] +- name: Ice Pyramid 2F South Tiled Room - To 1F + id: 122 + area: 36 + coordinates: [4, 26] + teleporter: [62, 0] +- name: Ice Pyramid 2F South Tiled Room - To 3F Two Boxes Room + id: 123 + area: 36 + coordinates: [22, 17] + teleporter: [67, 0] +- name: Ice Pyramid 2F West Room - To 1F + id: 124 + area: 36 + coordinates: [9, 10] + teleporter: [63, 0] +- name: Ice Pyramid 2F Center Room - To 1F + id: 125 + area: 36 + coordinates: [22, 14] + teleporter: [64, 0] +- name: Ice Pyramid 2F Small North Room - To 1F + id: 126 + area: 36 + coordinates: [26, 4] + teleporter: [65, 0] +- name: Ice Pyramid 2F North Corridor - To 1F + id: 127 + area: 36 + coordinates: [32, 8] + teleporter: [66, 0] +- name: Ice Pyramid 2F North Corridor - To 3F Main Loop + id: 128 + area: 36 + coordinates: [12, 7] + teleporter: [68, 0] +- name: Ice Pyramid 3F Two Boxes Room - To 2F South Tiled Room + id: 129 + area: 37 + coordinates: [24, 54] + teleporter: [69, 0] +- name: Ice Pyramid 3F Main Loop - To 2F Corridor + id: 130 + area: 37 + coordinates: [16, 45] + teleporter: [70, 0] +- name: Ice Pyramid 3F Main Loop - To 4F + id: 131 + area: 37 + coordinates: [19, 43] + teleporter: [71, 0] +- name: Ice Pyramid 4F Treasure Room - To 3F Main Loop + id: 132 + area: 38 + coordinates: [52, 5] + teleporter: [72, 0] +- name: Ice Pyramid 4F Treasure Room - To 5F Leap of Faith Room + id: 133 + area: 38 + coordinates: [62, 19] + teleporter: [73, 0] +- name: Ice Pyramid 5F Leap of Faith Room - To 4F Treasure Room + id: 134 + area: 39 + coordinates: [54, 63] + teleporter: [74, 0] +- name: Ice Pyramid 5F Leap of Faith Room - Bombed Ice Plate + id: 135 + area: 39 + coordinates: [47, 54] + teleporter: [77, 8] +- name: Ice Pyramid 5F Stairs to Ice Golem - To Ice Golem Room + id: 136 + area: 39 + coordinates: [39, 43] + teleporter: [75, 0] +- name: Ice Pyramid 5F Stairs to Ice Golem - To Climbing Wall Room + id: 137 + area: 39 + coordinates: [39, 60] + teleporter: [76, 0] +- name: Ice Pyramid - Duplicate Ice Golem Room # not used? + id: 138 + area: 40 + coordinates: [44, 43] + teleporter: [77, 0] +- name: Ice Pyramid Climbing Wall Room - To Taunt Room + id: 139 + area: 41 + coordinates: [4, 59] + teleporter: [78, 0] +- name: Ice Pyramid Climbing Wall Room - To 5F Stairs + id: 140 + area: 41 + coordinates: [4, 45] + teleporter: [79, 0] +- name: Ice Pyramid Ice Golem Room - To 5F Stairs + id: 141 + area: 42 + coordinates: [44, 43] + teleporter: [80, 0] +- name: Ice Pyramid Ice Golem Room - Ice Golem Script + id: 142 + area: 42 + coordinates: [53, 32] + teleporter: [10, 8] +- name: Spencer Waterfall - To Spencer Cave + id: 143 + area: 43 + coordinates: [48, 57] + teleporter: [81, 0] +- name: Spencer Waterfall - Upper Exit to Aquaria 1 + id: 144 + area: 43 + coordinates: [40, 5] + teleporter: [82, 0] +- name: Spencer Waterfall - Upper Exit to Aquaria 2 + id: 145 + area: 43 + coordinates: [40, 6] + teleporter: [82, 0] +- name: Spencer Waterfall - Upper Exit to Aquaria 3 + id: 146 + area: 43 + coordinates: [41, 5] + teleporter: [82, 0] +- name: Spencer Waterfall - Upper Exit to Aquaria 4 + id: 147 + area: 43 + coordinates: [41, 6] + teleporter: [82, 0] +- name: Spencer Waterfall - Right Exit to Aquaria 1 + id: 148 + area: 43 + coordinates: [46, 8] + teleporter: [83, 0] +- name: Spencer Waterfall - Right Exit to Aquaria 2 + id: 149 + area: 43 + coordinates: [47, 8] + teleporter: [83, 0] +- name: Spencer Cave Normal Main - To Waterfall + id: 150 + area: 44 + coordinates: [14, 39] + teleporter: [85, 0] +- name: Spencer Cave Normal From Overworld - Exit to Overworld + id: 151 + area: 44 + coordinates: [15, 57] + teleporter: [7, 6] +- name: Spencer Cave Unplug - Exit to Overworld + id: 152 + area: 45 + coordinates: [40, 29] + teleporter: [7, 6] +- name: Spencer Cave Unplug - Libra Teleporter Start Script + id: 153 + area: 45 + coordinates: [28, 21] + teleporter: [33, 8] +- name: Spencer Cave Unplug - Libra Teleporter End Script + id: 154 + area: 45 + coordinates: [46, 4] + teleporter: [34, 8] +- name: Spencer Cave Unplug - Mobius Teleporter Chest Script + id: 155 + area: 45 + coordinates: [21, 9] + teleporter: [35, 8] +- name: Spencer Cave Unplug - Mobius Teleporter Start Script + id: 156 + area: 45 + coordinates: [29, 28] + teleporter: [36, 8] +- name: Wintry Temple Outer Room - Main Entrance + id: 157 + area: 46 + coordinates: [8, 31] + teleporter: [15, 6] +- name: Wintry Temple Inner Room - Gemini Tile to Sealed temple + id: 158 + area: 46 + coordinates: [9, 24] + teleporter: [62, 8] +- name: Fireburg - To Overworld + id: 159 + area: 47 + coordinates: [4, 13] + teleporter: [9, 6] +- name: Fireburg - To Overworld + id: 160 + area: 47 + coordinates: [5, 13] + teleporter: [9, 6] +- name: Fireburg - To Overworld + id: 161 + area: 47 + coordinates: [28, 15] + teleporter: [9, 6] +- name: Fireburg - To Overworld + id: 162 + area: 47 + coordinates: [27, 15] + teleporter: [9, 6] +- name: Fireburg - Vendor House + id: 163 + area: 47 + coordinates: [10, 24] + teleporter: [91, 0] +- name: Fireburg - Reuben House + id: 164 + area: 47 + coordinates: [14, 6] + teleporter: [98, 8] # Script for reuben, original value [16, 2] +- name: Fireburg - Hotel + id: 165 + area: 47 + coordinates: [20, 8] + teleporter: [96, 8] # It's a script now for tristam, original value [17, 2] +- name: Fireburg - GrenadeMan House Script + id: 166 + area: 47 + coordinates: [12, 18] + teleporter: [11, 8] +- name: Reuben House - Main Entrance + id: 167 + area: 48 + coordinates: [33, 46] + teleporter: [98, 3] +- name: GrenadeMan House - Entrance Script + id: 168 + area: 49 + coordinates: [55, 60] + teleporter: [9, 8] +- name: GrenadeMan House - To Mobius Crest Room + id: 169 + area: 49 + coordinates: [57, 52] + teleporter: [93, 0] +- name: GrenadeMan Mobius Room - Stairs to House + id: 170 + area: 49 + coordinates: [39, 26] + teleporter: [94, 0] +- name: GrenadeMan Mobius Room - Mobius Teleporter Script + id: 171 + area: 49 + coordinates: [39, 23] + teleporter: [54, 8] +- name: Fireburg Vendor House - Entrance Script # No use to be a script + id: 172 + area: 49 + coordinates: [7, 10] + teleporter: [95, 0] # Original value [39, 8] +- name: Fireburg Vendor House - Stairs to Gemini Room + id: 173 + area: 49 + coordinates: [1, 4] + teleporter: [96, 0] +- name: Fireburg Gemini Room - Stairs to Vendor House + id: 174 + area: 49 + coordinates: [4, 39] + teleporter: [97, 0] +- name: Fireburg Gemini Room - Gemini Teleporter Script + id: 175 + area: 49 + coordinates: [2, 40] + teleporter: [45, 8] +- name: Fireburg Hotel Lobby - Stairs to beds + id: 176 + area: 49 + coordinates: [4, 50] + teleporter: [213, 0] +- name: Fireburg Hotel Lobby - Entrance + id: 177 + area: 49 + coordinates: [17, 56] + teleporter: [99, 3] +- name: Fireburg Hotel Beds - Stairs to Hotel Lobby + id: 178 + area: 49 + coordinates: [45, 59] + teleporter: [214, 0] +- name: Mine Exterior - Main Entrance + id: 179 + area: 50 + coordinates: [5, 28] + teleporter: [98, 0] +- name: Mine Exterior - To Cliff + id: 180 + area: 50 + coordinates: [58, 29] + teleporter: [99, 0] +- name: Mine Exterior - To Parallel Room + id: 181 + area: 50 + coordinates: [8, 7] + teleporter: [20, 2] +- name: Mine Exterior - To Crescent Room + id: 182 + area: 50 + coordinates: [26, 15] + teleporter: [21, 2] +- name: Mine Exterior - To Climbing Room + id: 183 + area: 50 + coordinates: [21, 35] + teleporter: [22, 2] +- name: Mine Exterior - Jinn Fight Script + id: 184 + area: 50 + coordinates: [58, 31] + teleporter: [74, 8] +- name: Mine Parallel Room - To Mine Exterior + id: 185 + area: 51 + coordinates: [7, 60] + teleporter: [100, 3] +- name: Mine Crescent Room - To Mine Exterior + id: 186 + area: 51 + coordinates: [22, 61] + teleporter: [101, 3] +- name: Mine Climbing Room - To Mine Exterior + id: 187 + area: 51 + coordinates: [56, 21] + teleporter: [102, 3] +- name: Mine Cliff - Entrance + id: 188 + area: 52 + coordinates: [9, 5] + teleporter: [100, 0] +- name: Mine Cliff - Reuben Grenade Script + id: 189 + area: 52 + coordinates: [15, 7] + teleporter: [12, 8] +- name: Sealed Temple - To Overworld + id: 190 + area: 53 + coordinates: [58, 43] + teleporter: [16, 6] +- name: Sealed Temple - Gemini Tile Script + id: 191 + area: 53 + coordinates: [56, 38] + teleporter: [63, 8] +- name: Volcano Base - Main Entrance 1 + id: 192 + area: 54 + coordinates: [23, 25] + teleporter: [103, 0] +- name: Volcano Base - Main Entrance 2 + id: 193 + area: 54 + coordinates: [23, 26] + teleporter: [103, 0] +- name: Volcano Base - Main Entrance 3 + id: 194 + area: 54 + coordinates: [24, 25] + teleporter: [103, 0] +- name: Volcano Base - Main Entrance 4 + id: 195 + area: 54 + coordinates: [24, 26] + teleporter: [103, 0] +- name: Volcano Base - Left Stairs Script + id: 196 + area: 54 + coordinates: [20, 5] + teleporter: [31, 8] +- name: Volcano Base - Right Stairs Script + id: 197 + area: 54 + coordinates: [32, 5] + teleporter: [30, 8] +- name: Volcano Top Right - Top Exit + id: 198 + area: 55 + coordinates: [44, 8] + teleporter: [9, 0] # Original value [103, 0] changed to volcano escape so floor shuffling doesn't pick it up +- name: Volcano Top Left - To Right-Left Path Script + id: 199 + area: 55 + coordinates: [40, 24] + teleporter: [26, 8] +- name: Volcano Top Right - To Left-Right Path Script + id: 200 + area: 55 + coordinates: [52, 24] + teleporter: [79, 8] # Original Value [26, 8] +- name: Volcano Right Path - To Volcano Base Script + id: 201 + area: 56 + coordinates: [48, 42] + teleporter: [15, 8] # Original Value [27, 8] +- name: Volcano Left Path - To Volcano Cross Left-Right + id: 202 + area: 56 + coordinates: [40, 31] + teleporter: [25, 2] +- name: Volcano Left Path - To Volcano Cross Right-Left + id: 203 + area: 56 + coordinates: [52, 29] + teleporter: [26, 2] +- name: Volcano Left Path - To Volcano Base Script + id: 204 + area: 56 + coordinates: [36, 42] + teleporter: [27, 8] +- name: Volcano Cross Left-Right - To Volcano Left Path + id: 205 + area: 56 + coordinates: [10, 42] + teleporter: [103, 3] +- name: Volcano Cross Left-Right - To Volcano Top Right Script + id: 206 + area: 56 + coordinates: [16, 24] + teleporter: [29, 8] +- name: Volcano Cross Right-Left - To Volcano Top Left Script + id: 207 + area: 56 + coordinates: [8, 22] + teleporter: [28, 8] +- name: Volcano Cross Right-Left - To Volcano Left Path + id: 208 + area: 56 + coordinates: [16, 42] + teleporter: [104, 3] +- name: Lava Dome Inner Ring Main Loop - Main Entrance 1 + id: 209 + area: 57 + coordinates: [32, 5] + teleporter: [104, 0] +- name: Lava Dome Inner Ring Main Loop - Main Entrance 2 + id: 210 + area: 57 + coordinates: [33, 5] + teleporter: [104, 0] +- name: Lava Dome Inner Ring Main Loop - To Three Steps Room + id: 211 + area: 57 + coordinates: [14, 5] + teleporter: [105, 0] +- name: Lava Dome Inner Ring Main Loop - To Life Chest Room Lower + id: 212 + area: 57 + coordinates: [40, 17] + teleporter: [106, 0] +- name: Lava Dome Inner Ring Main Loop - To Big Jump Room Left + id: 213 + area: 57 + coordinates: [8, 11] + teleporter: [108, 0] +- name: Lava Dome Inner Ring Main Loop - To Split Corridor Room + id: 214 + area: 57 + coordinates: [11, 19] + teleporter: [111, 0] +- name: Lava Dome Inner Ring Center Ledge - To Life Chest Room Higher + id: 215 + area: 57 + coordinates: [32, 11] + teleporter: [107, 0] +- name: Lava Dome Inner Ring Plate Ledge - To Plate Corridor + id: 216 + area: 57 + coordinates: [12, 23] + teleporter: [109, 0] +- name: Lava Dome Inner Ring Plate Ledge - Plate Script + id: 217 + area: 57 + coordinates: [5, 23] + teleporter: [47, 8] +- name: Lava Dome Inner Ring Upper Ledges - To Pointless Room + id: 218 + area: 57 + coordinates: [0, 9] + teleporter: [110, 0] +- name: Lava Dome Inner Ring Upper Ledges - To Lower Moon Helm Room + id: 219 + area: 57 + coordinates: [0, 15] + teleporter: [112, 0] +- name: Lava Dome Inner Ring Upper Ledges - To Up-Down Corridor + id: 220 + area: 57 + coordinates: [54, 5] + teleporter: [113, 0] +- name: Lava Dome Inner Ring Big Door Ledge - To Jumping Maze II + id: 221 + area: 57 + coordinates: [54, 21] + teleporter: [114, 0] +- name: Lava Dome Inner Ring Big Door Ledge - Hydra Gate 1 + id: 222 + area: 57 + coordinates: [62, 20] + teleporter: [29, 2] +- name: Lava Dome Inner Ring Big Door Ledge - Hydra Gate 2 + id: 223 + area: 57 + coordinates: [63, 20] + teleporter: [29, 2] +- name: Lava Dome Inner Ring Big Door Ledge - Hydra Gate 3 + id: 224 + area: 57 + coordinates: [62, 21] + teleporter: [29, 2] +- name: Lava Dome Inner Ring Big Door Ledge - Hydra Gate 4 + id: 225 + area: 57 + coordinates: [63, 21] + teleporter: [29, 2] +- name: Lava Dome Inner Ring Tiny Bottom Ledge - To Four Boxes Corridor + id: 226 + area: 57 + coordinates: [50, 25] + teleporter: [115, 0] +- name: Lava Dome Jump Maze II - Lower Right Entrance + id: 227 + area: 58 + coordinates: [55, 28] + teleporter: [116, 0] +- name: Lava Dome Jump Maze II - Upper Entrance + id: 228 + area: 58 + coordinates: [35, 3] + teleporter: [119, 0] +- name: Lava Dome Jump Maze II - Lower Left Entrance + id: 229 + area: 58 + coordinates: [34, 27] + teleporter: [120, 0] +- name: Lava Dome Up-Down Corridor - Upper Entrance + id: 230 + area: 58 + coordinates: [29, 8] + teleporter: [117, 0] +- name: Lava Dome Up-Down Corridor - Lower Entrance + id: 231 + area: 58 + coordinates: [28, 25] + teleporter: [118, 0] +- name: Lava Dome Jump Maze I - South Entrance + id: 232 + area: 59 + coordinates: [20, 27] + teleporter: [121, 0] +- name: Lava Dome Jump Maze I - North Entrance + id: 233 + area: 59 + coordinates: [7, 3] + teleporter: [122, 0] +- name: Lava Dome Pointless Room - Entrance + id: 234 + area: 60 + coordinates: [2, 7] + teleporter: [123, 0] +- name: Lava Dome Pointless Room - Visit Quest Script 1 + id: 490 + area: 60 + coordinates: [4, 4] + teleporter: [99, 8] +- name: Lava Dome Pointless Room - Visit Quest Script 2 + id: 491 + area: 60 + coordinates: [4, 5] + teleporter: [99, 8] +- name: Lava Dome Lower Moon Helm Room - Left Entrance + id: 235 + area: 60 + coordinates: [2, 19] + teleporter: [124, 0] +- name: Lava Dome Lower Moon Helm Room - Right Entrance + id: 236 + area: 60 + coordinates: [11, 21] + teleporter: [125, 0] +- name: Lava Dome Moon Helm Room - Entrance + id: 237 + area: 60 + coordinates: [15, 23] + teleporter: [126, 0] +- name: Lava Dome Three Jumps Room - To Main Loop + id: 238 + area: 61 + coordinates: [58, 15] + teleporter: [127, 0] +- name: Lava Dome Life Chest Room - Lower South Entrance + id: 239 + area: 61 + coordinates: [38, 27] + teleporter: [128, 0] +- name: Lava Dome Life Chest Room - Upper South Entrance + id: 240 + area: 61 + coordinates: [28, 23] + teleporter: [129, 0] +- name: Lava Dome Big Jump Room - Left Entrance + id: 241 + area: 62 + coordinates: [42, 51] + teleporter: [133, 0] +- name: Lava Dome Big Jump Room - North Entrance + id: 242 + area: 62 + coordinates: [30, 29] + teleporter: [131, 0] +- name: Lava Dome Big Jump Room - Lower Right Stairs + id: 243 + area: 62 + coordinates: [61, 59] + teleporter: [132, 0] +- name: Lava Dome Split Corridor - Upper Stairs + id: 244 + area: 62 + coordinates: [30, 43] + teleporter: [130, 0] +- name: Lava Dome Split Corridor - Lower Stairs + id: 245 + area: 62 + coordinates: [36, 61] + teleporter: [134, 0] +- name: Lava Dome Plate Corridor - Right Entrance + id: 246 + area: 63 + coordinates: [19, 29] + teleporter: [135, 0] +- name: Lava Dome Plate Corridor - Left Entrance + id: 247 + area: 63 + coordinates: [60, 21] + teleporter: [137, 0] +- name: Lava Dome Four Boxes Stairs - Upper Entrance + id: 248 + area: 63 + coordinates: [22, 3] + teleporter: [136, 0] +- name: Lava Dome Four Boxes Stairs - Lower Entrance + id: 249 + area: 63 + coordinates: [22, 17] + teleporter: [16, 0] +- name: Lava Dome Hydra Room - South Entrance + id: 250 + area: 64 + coordinates: [14, 59] + teleporter: [105, 3] +- name: Lava Dome Hydra Room - North Exit + id: 251 + area: 64 + coordinates: [25, 31] + teleporter: [138, 0] +- name: Lava Dome Hydra Room - Hydra Script + id: 252 + area: 64 + coordinates: [14, 36] + teleporter: [14, 8] +- name: Lava Dome Escape Corridor - South Entrance + id: 253 + area: 65 + coordinates: [22, 17] + teleporter: [139, 0] +- name: Lava Dome Escape Corridor - North Entrance + id: 254 + area: 65 + coordinates: [22, 3] + teleporter: [9, 0] +- name: Rope Bridge - West Entrance 1 + id: 255 + area: 66 + coordinates: [3, 10] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 2 + id: 256 + area: 66 + coordinates: [3, 11] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 3 + id: 257 + area: 66 + coordinates: [3, 12] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 4 + id: 258 + area: 66 + coordinates: [3, 13] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 5 + id: 259 + area: 66 + coordinates: [4, 10] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 6 + id: 260 + area: 66 + coordinates: [4, 11] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 7 + id: 261 + area: 66 + coordinates: [4, 12] + teleporter: [140, 0] +- name: Rope Bridge - West Entrance 8 + id: 262 + area: 66 + coordinates: [4, 13] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 1 + id: 263 + area: 66 + coordinates: [59, 10] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 2 + id: 264 + area: 66 + coordinates: [59, 11] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 3 + id: 265 + area: 66 + coordinates: [59, 12] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 4 + id: 266 + area: 66 + coordinates: [59, 13] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 5 + id: 267 + area: 66 + coordinates: [60, 10] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 6 + id: 268 + area: 66 + coordinates: [60, 11] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 7 + id: 269 + area: 66 + coordinates: [60, 12] + teleporter: [140, 0] +- name: Rope Bridge - East Entrance 8 + id: 270 + area: 66 + coordinates: [60, 13] + teleporter: [140, 0] +- name: Rope Bridge - Reuben Fall Script + id: 271 + area: 66 + coordinates: [13, 12] + teleporter: [15, 8] +- name: Alive Forest - West Entrance 1 + id: 272 + area: 67 + coordinates: [8, 13] + teleporter: [142, 0] +- name: Alive Forest - West Entrance 2 + id: 273 + area: 67 + coordinates: [9, 13] + teleporter: [142, 0] +- name: Alive Forest - Giant Tree Entrance + id: 274 + area: 67 + coordinates: [42, 42] + teleporter: [143, 0] +- name: Alive Forest - Libra Teleporter Script + id: 275 + area: 67 + coordinates: [8, 52] + teleporter: [64, 8] +- name: Alive Forest - Gemini Teleporter Script + id: 276 + area: 67 + coordinates: [57, 49] + teleporter: [65, 8] +- name: Alive Forest - Mobius Teleporter Script + id: 277 + area: 67 + coordinates: [24, 10] + teleporter: [66, 8] +- name: Giant Tree 1F - Entrance Script 1 + id: 278 + area: 68 + coordinates: [18, 31] + teleporter: [56, 1] # The script is restored if no map shuffling [49, 8] +- name: Giant Tree 1F - Entrance Script 2 + id: 279 + area: 68 + coordinates: [19, 31] + teleporter: [56, 1] # Same [49, 8] +- name: Giant Tree 1F - North Entrance To 2F + id: 280 + area: 68 + coordinates: [16, 1] + teleporter: [144, 0] +- name: Giant Tree 2F Main Lobby - North Entrance to 1F + id: 281 + area: 69 + coordinates: [44, 33] + teleporter: [145, 0] +- name: Giant Tree 2F Main Lobby - Central Entrance to 3F + id: 282 + area: 69 + coordinates: [42, 47] + teleporter: [146, 0] +- name: Giant Tree 2F Main Lobby - West Entrance to Mushroom Room + id: 283 + area: 69 + coordinates: [58, 49] + teleporter: [149, 0] +- name: Giant Tree 2F West Ledge - To 3F Northwest Ledge + id: 284 + area: 69 + coordinates: [34, 37] + teleporter: [147, 0] +- name: Giant Tree 2F Fall From Vine Script + id: 482 + area: 69 + coordinates: [0x2E, 0x33] + teleporter: [76, 8] +- name: Giant Tree Meteor Chest Room - To 2F Mushroom Room + id: 285 + area: 69 + coordinates: [58, 44] + teleporter: [148, 0] +- name: Giant Tree 2F Mushroom Room - Entrance + id: 286 + area: 70 + coordinates: [55, 18] + teleporter: [150, 0] +- name: Giant Tree 2F Mushroom Room - North Face to Meteor + id: 287 + area: 70 + coordinates: [56, 7] + teleporter: [151, 0] +- name: Giant Tree 3F Central Room - Central Entrance to 2F + id: 288 + area: 71 + coordinates: [46, 53] + teleporter: [152, 0] +- name: Giant Tree 3F Central Room - East Entrance to Worm Room + id: 289 + area: 71 + coordinates: [58, 39] + teleporter: [153, 0] +- name: Giant Tree 3F Lower Corridor - Entrance from Worm Room + id: 290 + area: 71 + coordinates: [45, 39] + teleporter: [154, 0] +- name: Giant Tree 3F West Platform - Lower Entrance + id: 291 + area: 71 + coordinates: [33, 43] + teleporter: [155, 0] +- name: Giant Tree 3F West Platform - Top Entrance + id: 292 + area: 71 + coordinates: [52, 25] + teleporter: [156, 0] +- name: Giant Tree Worm Room - East Entrance + id: 293 + area: 72 + coordinates: [20, 58] + teleporter: [157, 0] +- name: Giant Tree Worm Room - West Entrance + id: 294 + area: 72 + coordinates: [6, 56] + teleporter: [158, 0] +- name: Giant Tree 4F Lower Floor - Entrance + id: 295 + area: 73 + coordinates: [20, 7] + teleporter: [159, 0] +- name: Giant Tree 4F Lower Floor - Lower West Mouth + id: 296 + area: 73 + coordinates: [8, 23] + teleporter: [160, 0] +- name: Giant Tree 4F Lower Floor - Lower Central Mouth + id: 297 + area: 73 + coordinates: [14, 25] + teleporter: [161, 0] +- name: Giant Tree 4F Lower Floor - Lower East Mouth + id: 298 + area: 73 + coordinates: [20, 25] + teleporter: [162, 0] +- name: Giant Tree 4F Upper Floor - Upper West Mouth + id: 299 + area: 73 + coordinates: [8, 19] + teleporter: [163, 0] +- name: Giant Tree 4F Upper Floor - Upper Central Mouth + id: 300 + area: 73 + coordinates: [12, 17] + teleporter: [164, 0] +- name: Giant Tree 4F Slime Room - Exit + id: 301 + area: 74 + coordinates: [47, 10] + teleporter: [165, 0] +- name: Giant Tree 4F Slime Room - West Entrance + id: 302 + area: 74 + coordinates: [45, 24] + teleporter: [166, 0] +- name: Giant Tree 4F Slime Room - Central Entrance + id: 303 + area: 74 + coordinates: [50, 24] + teleporter: [167, 0] +- name: Giant Tree 4F Slime Room - East Entrance + id: 304 + area: 74 + coordinates: [57, 28] + teleporter: [168, 0] +- name: Giant Tree 5F - Entrance + id: 305 + area: 75 + coordinates: [14, 51] + teleporter: [169, 0] +- name: Giant Tree 5F - Giant Tree Face # Unused + id: 306 + area: 75 + coordinates: [14, 37] + teleporter: [170, 0] +- name: Kaidge Temple - Entrance + id: 307 + area: 77 + coordinates: [44, 63] + teleporter: [18, 6] +- name: Kaidge Temple - Mobius Teleporter Script + id: 308 + area: 77 + coordinates: [35, 57] + teleporter: [71, 8] +- name: Windhole Temple - Entrance + id: 309 + area: 78 + coordinates: [10, 29] + teleporter: [173, 0] +- name: Mount Gale - Entrance 1 + id: 310 + area: 79 + coordinates: [1, 45] + teleporter: [174, 0] +- name: Mount Gale - Entrance 2 + id: 311 + area: 79 + coordinates: [2, 45] + teleporter: [174, 0] +- name: Mount Gale - Visit Quest + id: 494 + area: 79 + coordinates: [44, 7] + teleporter: [101, 8] +- name: Windia - Main Entrance 1 + id: 312 + area: 80 + coordinates: [12, 40] + teleporter: [10, 6] +- name: Windia - Main Entrance 2 + id: 313 + area: 80 + coordinates: [13, 40] + teleporter: [10, 6] +- name: Windia - Main Entrance 3 + id: 314 + area: 80 + coordinates: [14, 40] + teleporter: [10, 6] +- name: Windia - Main Entrance 4 + id: 315 + area: 80 + coordinates: [15, 40] + teleporter: [10, 6] +- name: Windia - Main Entrance 5 + id: 316 + area: 80 + coordinates: [12, 41] + teleporter: [10, 6] +- name: Windia - Main Entrance 6 + id: 317 + area: 80 + coordinates: [13, 41] + teleporter: [10, 6] +- name: Windia - Main Entrance 7 + id: 318 + area: 80 + coordinates: [14, 41] + teleporter: [10, 6] +- name: Windia - Main Entrance 8 + id: 319 + area: 80 + coordinates: [15, 41] + teleporter: [10, 6] +- name: Windia - Otto's House + id: 320 + area: 80 + coordinates: [21, 39] + teleporter: [30, 5] +- name: Windia - INN's Script # Change to teleporter / Change back to script! + id: 321 + area: 80 + coordinates: [18, 34] + teleporter: [97, 8] # Original value [79, 8] > [31, 2] +- name: Windia - Vendor House + id: 322 + area: 80 + coordinates: [8, 36] + teleporter: [32, 5] +- name: Windia - Kid House + id: 323 + area: 80 + coordinates: [7, 23] + teleporter: [176, 4] +- name: Windia - Old People House + id: 324 + area: 80 + coordinates: [19, 21] + teleporter: [177, 4] +- name: Windia - Rainbow Bridge Script + id: 325 + area: 80 + coordinates: [21, 9] + teleporter: [10, 6] # Change to entrance, usually a script [41, 8] +- name: Otto's House - Attic Stairs + id: 326 + area: 81 + coordinates: [2, 19] + teleporter: [33, 2] +- name: Otto's House - Entrance + id: 327 + area: 81 + coordinates: [9, 30] + teleporter: [106, 3] +- name: Otto's Attic - Stairs + id: 328 + area: 81 + coordinates: [26, 23] + teleporter: [107, 3] +- name: Windia Kid House - Entrance Script # Change to teleporter + id: 329 + area: 82 + coordinates: [7, 10] + teleporter: [178, 0] # Original value [38, 8] +- name: Windia Kid House - Basement Stairs + id: 330 + area: 82 + coordinates: [1, 4] + teleporter: [180, 0] +- name: Windia Old People House - Entrance + id: 331 + area: 82 + coordinates: [55, 12] + teleporter: [179, 0] +- name: Windia Old People House - Basement Stairs + id: 332 + area: 82 + coordinates: [60, 5] + teleporter: [181, 0] +- name: Windia Kid House Basement - Stairs + id: 333 + area: 82 + coordinates: [43, 8] + teleporter: [182, 0] +- name: Windia Kid House Basement - Mobius Teleporter + id: 334 + area: 82 + coordinates: [41, 9] + teleporter: [44, 8] +- name: Windia Old People House Basement - Stairs + id: 335 + area: 82 + coordinates: [39, 26] + teleporter: [183, 0] +- name: Windia Old People House Basement - Mobius Teleporter Script + id: 336 + area: 82 + coordinates: [39, 23] + teleporter: [43, 8] +- name: Windia Inn Lobby - Stairs to Beds + id: 337 + area: 82 + coordinates: [45, 24] + teleporter: [102, 8] # Changed to script, original value [215, 0] +- name: Windia Inn Lobby - Exit + id: 338 + area: 82 + coordinates: [53, 30] + teleporter: [135, 3] +- name: Windia Inn Beds - Stairs to Lobby + id: 339 + area: 82 + coordinates: [33, 59] + teleporter: [216, 0] +- name: Windia Vendor House - Entrance + id: 340 + area: 82 + coordinates: [29, 14] + teleporter: [108, 3] +- name: Pazuzu Tower 1F Main Lobby - Main Entrance 1 + id: 341 + area: 83 + coordinates: [47, 29] + teleporter: [184, 0] +- name: Pazuzu Tower 1F Main Lobby - Main Entrance 2 + id: 342 + area: 83 + coordinates: [47, 30] + teleporter: [184, 0] +- name: Pazuzu Tower 1F Main Lobby - Main Entrance 3 + id: 343 + area: 83 + coordinates: [48, 29] + teleporter: [184, 0] +- name: Pazuzu Tower 1F Main Lobby - Main Entrance 4 + id: 344 + area: 83 + coordinates: [48, 30] + teleporter: [184, 0] +- name: Pazuzu Tower 1F Main Lobby - East Entrance + id: 345 + area: 83 + coordinates: [55, 12] + teleporter: [185, 0] +- name: Pazuzu Tower 1F Main Lobby - South Stairs + id: 346 + area: 83 + coordinates: [51, 25] + teleporter: [186, 0] +- name: Pazuzu Tower 1F Main Lobby - Pazuzu Script 1 + id: 347 + area: 83 + coordinates: [47, 8] + teleporter: [16, 8] +- name: Pazuzu Tower 1F Main Lobby - Pazuzu Script 2 + id: 348 + area: 83 + coordinates: [48, 8] + teleporter: [16, 8] +- name: Pazuzu Tower 1F Boxes Room - West Stairs + id: 349 + area: 83 + coordinates: [38, 17] + teleporter: [187, 0] +- name: Pazuzu 2F - West Upper Stairs + id: 350 + area: 84 + coordinates: [7, 11] + teleporter: [188, 0] +- name: Pazuzu 2F - South Stairs + id: 351 + area: 84 + coordinates: [20, 24] + teleporter: [189, 0] +- name: Pazuzu 2F - West Lower Stairs + id: 352 + area: 84 + coordinates: [6, 17] + teleporter: [190, 0] +- name: Pazuzu 2F - Central Stairs + id: 353 + area: 84 + coordinates: [15, 15] + teleporter: [191, 0] +- name: Pazuzu 2F - Pazuzu 1 + id: 354 + area: 84 + coordinates: [15, 8] + teleporter: [17, 8] +- name: Pazuzu 2F - Pazuzu 2 + id: 355 + area: 84 + coordinates: [16, 8] + teleporter: [17, 8] +- name: Pazuzu 3F Main Room - North Stairs + id: 356 + area: 85 + coordinates: [23, 11] + teleporter: [192, 0] +- name: Pazuzu 3F Main Room - West Stairs + id: 357 + area: 85 + coordinates: [7, 15] + teleporter: [193, 0] +- name: Pazuzu 3F Main Room - Pazuzu Script 1 + id: 358 + area: 85 + coordinates: [15, 8] + teleporter: [18, 8] +- name: Pazuzu 3F Main Room - Pazuzu Script 2 + id: 359 + area: 85 + coordinates: [16, 8] + teleporter: [18, 8] +- name: Pazuzu 3F Central Island - Central Stairs + id: 360 + area: 85 + coordinates: [15, 14] + teleporter: [194, 0] +- name: Pazuzu 3F Central Island - South Stairs + id: 361 + area: 85 + coordinates: [17, 25] + teleporter: [195, 0] +- name: Pazuzu 4F - Northwest Stairs + id: 362 + area: 86 + coordinates: [39, 12] + teleporter: [196, 0] +- name: Pazuzu 4F - Southwest Stairs + id: 363 + area: 86 + coordinates: [39, 19] + teleporter: [197, 0] +- name: Pazuzu 4F - South Stairs + id: 364 + area: 86 + coordinates: [47, 24] + teleporter: [198, 0] +- name: Pazuzu 4F - Northeast Stairs + id: 365 + area: 86 + coordinates: [54, 9] + teleporter: [199, 0] +- name: Pazuzu 4F - Pazuzu Script 1 + id: 366 + area: 86 + coordinates: [47, 8] + teleporter: [19, 8] +- name: Pazuzu 4F - Pazuzu Script 2 + id: 367 + area: 86 + coordinates: [48, 8] + teleporter: [19, 8] +- name: Pazuzu 5F Pazuzu Loop - West Stairs + id: 368 + area: 87 + coordinates: [9, 49] + teleporter: [200, 0] +- name: Pazuzu 5F Pazuzu Loop - South Stairs + id: 369 + area: 87 + coordinates: [16, 55] + teleporter: [201, 0] +- name: Pazuzu 5F Upper Loop - Northeast Stairs + id: 370 + area: 87 + coordinates: [22, 40] + teleporter: [202, 0] +- name: Pazuzu 5F Upper Loop - Northwest Stairs + id: 371 + area: 87 + coordinates: [9, 40] + teleporter: [203, 0] +- name: Pazuzu 5F Upper Loop - Pazuzu Script 1 + id: 372 + area: 87 + coordinates: [15, 40] + teleporter: [20, 8] +- name: Pazuzu 5F Upper Loop - Pazuzu Script 2 + id: 373 + area: 87 + coordinates: [16, 40] + teleporter: [20, 8] +- name: Pazuzu 6F - West Stairs + id: 374 + area: 88 + coordinates: [41, 47] + teleporter: [204, 0] +- name: Pazuzu 6F - Northwest Stairs + id: 375 + area: 88 + coordinates: [41, 40] + teleporter: [205, 0] +- name: Pazuzu 6F - Northeast Stairs + id: 376 + area: 88 + coordinates: [54, 40] + teleporter: [206, 0] +- name: Pazuzu 6F - South Stairs + id: 377 + area: 88 + coordinates: [52, 56] + teleporter: [207, 0] +- name: Pazuzu 6F - Pazuzu Script 1 + id: 378 + area: 88 + coordinates: [47, 40] + teleporter: [21, 8] +- name: Pazuzu 6F - Pazuzu Script 2 + id: 379 + area: 88 + coordinates: [48, 40] + teleporter: [21, 8] +- name: Pazuzu 7F Main Room - Southwest Stairs + id: 380 + area: 89 + coordinates: [15, 54] + teleporter: [26, 0] +- name: Pazuzu 7F Main Room - Northeast Stairs + id: 381 + area: 89 + coordinates: [21, 40] + teleporter: [27, 0] +- name: Pazuzu 7F Main Room - Southeast Stairs + id: 382 + area: 89 + coordinates: [21, 56] + teleporter: [28, 0] +- name: Pazuzu 7F Main Room - Pazuzu Script 1 + id: 383 + area: 89 + coordinates: [15, 44] + teleporter: [22, 8] +- name: Pazuzu 7F Main Room - Pazuzu Script 2 + id: 384 + area: 89 + coordinates: [16, 44] + teleporter: [22, 8] +- name: Pazuzu 7F Main Room - Crystal Script # Added for floor shuffle + id: 480 + area: 89 + coordinates: [15, 40] + teleporter: [38, 8] +- name: Pazuzu 1F to 3F - South Stairs + id: 385 + area: 90 + coordinates: [43, 60] + teleporter: [29, 0] +- name: Pazuzu 1F to 3F - North Stairs + id: 386 + area: 90 + coordinates: [43, 36] + teleporter: [30, 0] +- name: Pazuzu 3F to 5F - South Stairs + id: 387 + area: 91 + coordinates: [43, 60] + teleporter: [40, 0] +- name: Pazuzu 3F to 5F - North Stairs + id: 388 + area: 91 + coordinates: [43, 36] + teleporter: [41, 0] +- name: Pazuzu 5F to 7F - South Stairs + id: 389 + area: 92 + coordinates: [43, 60] + teleporter: [38, 0] +- name: Pazuzu 5F to 7F - North Stairs + id: 390 + area: 92 + coordinates: [43, 36] + teleporter: [39, 0] +- name: Pazuzu 2F to 4F - South Stairs + id: 391 + area: 93 + coordinates: [43, 60] + teleporter: [21, 0] +- name: Pazuzu 2F to 4F - North Stairs + id: 392 + area: 93 + coordinates: [43, 36] + teleporter: [22, 0] +- name: Pazuzu 4F to 6F - South Stairs + id: 393 + area: 94 + coordinates: [43, 60] + teleporter: [2, 0] +- name: Pazuzu 4F to 6F - North Stairs + id: 394 + area: 94 + coordinates: [43, 36] + teleporter: [3, 0] +- name: Light Temple - Entrance + id: 395 + area: 95 + coordinates: [28, 57] + teleporter: [19, 6] +- name: Light Temple - Mobius Teleporter Script + id: 396 + area: 95 + coordinates: [29, 37] + teleporter: [70, 8] +- name: Light Temple - Visit Quest Script 1 + id: 492 + area: 95 + coordinates: [34, 39] + teleporter: [100, 8] +- name: Light Temple - Visit Quest Script 2 + id: 493 + area: 95 + coordinates: [35, 39] + teleporter: [100, 8] +- name: Ship Dock - Mobius Teleporter Script + id: 397 + area: 96 + coordinates: [15, 18] + teleporter: [61, 8] +- name: Ship Dock - From Overworld + id: 398 + area: 96 + coordinates: [15, 11] + teleporter: [73, 0] +- name: Ship Dock - Entrance + id: 399 + area: 96 + coordinates: [15, 23] + teleporter: [17, 6] +- name: Mac Ship Deck - East Entrance Script + id: 400 + area: 97 + coordinates: [26, 40] + teleporter: [37, 8] +- name: Mac Ship Deck - Central Stairs Script + id: 401 + area: 97 + coordinates: [16, 47] + teleporter: [50, 8] +- name: Mac Ship Deck - West Stairs Script + id: 402 + area: 97 + coordinates: [8, 34] + teleporter: [51, 8] +- name: Mac Ship Deck - East Stairs Script + id: 403 + area: 97 + coordinates: [24, 36] + teleporter: [52, 8] +- name: Mac Ship Deck - North Stairs Script + id: 404 + area: 97 + coordinates: [12, 9] + teleporter: [53, 8] +- name: Mac Ship B1 Outer Ring - South Stairs + id: 405 + area: 98 + coordinates: [16, 45] + teleporter: [208, 0] +- name: Mac Ship B1 Outer Ring - West Stairs + id: 406 + area: 98 + coordinates: [8, 35] + teleporter: [175, 0] +- name: Mac Ship B1 Outer Ring - East Stairs + id: 407 + area: 98 + coordinates: [25, 37] + teleporter: [172, 0] +- name: Mac Ship B1 Outer Ring - Northwest Stairs + id: 408 + area: 98 + coordinates: [10, 23] + teleporter: [88, 0] +- name: Mac Ship B1 Square Room - North Stairs + id: 409 + area: 98 + coordinates: [14, 9] + teleporter: [141, 0] +- name: Mac Ship B1 Square Room - South Stairs + id: 410 + area: 98 + coordinates: [16, 12] + teleporter: [87, 0] +- name: Mac Ship B1 Mac Room - Stairs # Unused? + id: 411 + area: 98 + coordinates: [16, 51] + teleporter: [101, 0] +- name: Mac Ship B1 Central Corridor - South Stairs + id: 412 + area: 98 + coordinates: [16, 38] + teleporter: [102, 0] +- name: Mac Ship B1 Central Corridor - North Stairs + id: 413 + area: 98 + coordinates: [16, 26] + teleporter: [86, 0] +- name: Mac Ship B2 South Corridor - South Stairs + id: 414 + area: 99 + coordinates: [48, 51] + teleporter: [57, 1] +- name: Mac Ship B2 South Corridor - North Stairs Script + id: 415 + area: 99 + coordinates: [48, 38] + teleporter: [55, 8] +- name: Mac Ship B2 North Corridor - South Stairs Script + id: 416 + area: 99 + coordinates: [48, 27] + teleporter: [56, 8] +- name: Mac Ship B2 North Corridor - North Stairs Script + id: 417 + area: 99 + coordinates: [48, 12] + teleporter: [57, 8] +- name: Mac Ship B2 Outer Ring - Northwest Stairs Script + id: 418 + area: 99 + coordinates: [55, 11] + teleporter: [58, 8] +- name: Mac Ship B1 Outer Ring Cleared - South Stairs + id: 419 + area: 100 + coordinates: [16, 45] + teleporter: [208, 0] +- name: Mac Ship B1 Outer Ring Cleared - West Stairs + id: 420 + area: 100 + coordinates: [8, 35] + teleporter: [175, 0] +- name: Mac Ship B1 Outer Ring Cleared - East Stairs + id: 421 + area: 100 + coordinates: [25, 37] + teleporter: [172, 0] +- name: Mac Ship B1 Square Room Cleared - North Stairs + id: 422 + area: 100 + coordinates: [14, 9] + teleporter: [141, 0] +- name: Mac Ship B1 Square Room Cleared - South Stairs + id: 423 + area: 100 + coordinates: [16, 12] + teleporter: [87, 0] +- name: Mac Ship B1 Mac Room Cleared - Main Stairs + id: 424 + area: 100 + coordinates: [16, 51] + teleporter: [101, 0] +- name: Mac Ship B1 Central Corridor Cleared - South Stairs + id: 425 + area: 100 + coordinates: [16, 38] + teleporter: [102, 0] +- name: Mac Ship B1 Central Corridor Cleared - North Stairs + id: 426 + area: 100 + coordinates: [16, 26] + teleporter: [86, 0] +- name: Mac Ship B1 Central Corridor Cleared - Northwest Stairs + id: 427 + area: 100 + coordinates: [23, 10] + teleporter: [88, 0] +- name: Doom Castle Corridor of Destiny - South Entrance + id: 428 + area: 101 + coordinates: [59, 29] + teleporter: [84, 0] +- name: Doom Castle Corridor of Destiny - Ice Floor Entrance + id: 429 + area: 101 + coordinates: [59, 21] + teleporter: [35, 2] +- name: Doom Castle Corridor of Destiny - Lava Floor Entrance + id: 430 + area: 101 + coordinates: [59, 13] + teleporter: [209, 0] +- name: Doom Castle Corridor of Destiny - Sky Floor Entrance + id: 431 + area: 101 + coordinates: [59, 5] + teleporter: [211, 0] +- name: Doom Castle Corridor of Destiny - Hero Room Entrance + id: 432 + area: 101 + coordinates: [59, 61] + teleporter: [13, 2] +- name: Doom Castle Ice Floor - Entrance + id: 433 + area: 102 + coordinates: [23, 42] + teleporter: [109, 3] +- name: Doom Castle Lava Floor - Entrance + id: 434 + area: 103 + coordinates: [23, 40] + teleporter: [210, 0] +- name: Doom Castle Sky Floor - Entrance + id: 435 + area: 104 + coordinates: [24, 41] + teleporter: [212, 0] +- name: Doom Castle Hero Room - Dark King Entrance 1 + id: 436 + area: 106 + coordinates: [15, 5] + teleporter: [54, 0] +- name: Doom Castle Hero Room - Dark King Entrance 2 + id: 437 + area: 106 + coordinates: [16, 5] + teleporter: [54, 0] +- name: Doom Castle Hero Room - Dark King Entrance 3 + id: 438 + area: 106 + coordinates: [15, 4] + teleporter: [54, 0] +- name: Doom Castle Hero Room - Dark King Entrance 4 + id: 439 + area: 106 + coordinates: [16, 4] + teleporter: [54, 0] +- name: Doom Castle Hero Room - Hero Statue Script + id: 440 + area: 106 + coordinates: [15, 17] + teleporter: [24, 8] +- name: Doom Castle Hero Room - Entrance + id: 441 + area: 106 + coordinates: [15, 24] + teleporter: [110, 3] +- name: Doom Castle Dark King Room - Entrance + id: 442 + area: 107 + coordinates: [14, 26] + teleporter: [52, 0] +- name: Doom Castle Dark King Room - Dark King Script + id: 443 + area: 107 + coordinates: [14, 15] + teleporter: [25, 8] +- name: Doom Castle Dark King Room - Unknown + id: 444 + area: 107 + coordinates: [47, 54] + teleporter: [77, 0] +- name: Overworld - Level Forest + id: 445 + area: 0 + type: "Overworld" + teleporter: [0x2E, 8] +- name: Overworld - Foresta + id: 446 + area: 0 + type: "Overworld" + teleporter: [0x02, 1] +- name: Overworld - Sand Temple + id: 447 + area: 0 + type: "Overworld" + teleporter: [0x03, 1] +- name: Overworld - Bone Dungeon + id: 448 + area: 0 + type: "Overworld" + teleporter: [0x04, 1] +- name: Overworld - Focus Tower Foresta + id: 449 + area: 0 + type: "Overworld" + teleporter: [0x05, 1] +- name: Overworld - Focus Tower Aquaria + id: 450 + area: 0 + type: "Overworld" + teleporter: [0x13, 1] +- name: Overworld - Libra Temple + id: 451 + area: 0 + type: "Overworld" + teleporter: [0x07, 1] +- name: Overworld - Aquaria + id: 452 + area: 0 + type: "Overworld" + teleporter: [0x08, 8] +- name: Overworld - Wintry Cave + id: 453 + area: 0 + type: "Overworld" + teleporter: [0x0A, 1] +- name: Overworld - Life Temple + id: 454 + area: 0 + type: "Overworld" + teleporter: [0x0B, 1] +- name: Overworld - Falls Basin + id: 455 + area: 0 + type: "Overworld" + teleporter: [0x0C, 1] +- name: Overworld - Ice Pyramid + id: 456 + area: 0 + type: "Overworld" + teleporter: [0x0D, 1] # Will be switched to a script +- name: Overworld - Spencer's Place + id: 457 + area: 0 + type: "Overworld" + teleporter: [0x30, 8] +- name: Overworld - Wintry Temple + id: 458 + area: 0 + type: "Overworld" + teleporter: [0x10, 1] +- name: Overworld - Focus Tower Frozen Strip + id: 459 + area: 0 + type: "Overworld" + teleporter: [0x11, 1] +- name: Overworld - Focus Tower Fireburg + id: 460 + area: 0 + type: "Overworld" + teleporter: [0x12, 1] +- name: Overworld - Fireburg + id: 461 + area: 0 + type: "Overworld" + teleporter: [0x14, 1] +- name: Overworld - Mine + id: 462 + area: 0 + type: "Overworld" + teleporter: [0x15, 1] +- name: Overworld - Sealed Temple + id: 463 + area: 0 + type: "Overworld" + teleporter: [0x16, 1] +- name: Overworld - Volcano + id: 464 + area: 0 + type: "Overworld" + teleporter: [0x17, 1] +- name: Overworld - Lava Dome + id: 465 + area: 0 + type: "Overworld" + teleporter: [0x18, 1] +- name: Overworld - Focus Tower Windia + id: 466 + area: 0 + type: "Overworld" + teleporter: [0x06, 1] +- name: Overworld - Rope Bridge + id: 467 + area: 0 + type: "Overworld" + teleporter: [0x19, 1] +- name: Overworld - Alive Forest + id: 468 + area: 0 + type: "Overworld" + teleporter: [0x1A, 1] +- name: Overworld - Giant Tree + id: 469 + area: 0 + type: "Overworld" + teleporter: [0x1B, 1] +- name: Overworld - Kaidge Temple + id: 470 + area: 0 + type: "Overworld" + teleporter: [0x1C, 1] +- name: Overworld - Windia + id: 471 + area: 0 + type: "Overworld" + teleporter: [0x1D, 1] +- name: Overworld - Windhole Temple + id: 472 + area: 0 + type: "Overworld" + teleporter: [0x1E, 1] +- name: Overworld - Mount Gale + id: 473 + area: 0 + type: "Overworld" + teleporter: [0x1F, 1] +- name: Overworld - Pazuzu Tower + id: 474 + area: 0 + type: "Overworld" + teleporter: [0x20, 1] +- name: Overworld - Ship Dock + id: 475 + area: 0 + type: "Overworld" + teleporter: [0x3E, 1] +- name: Overworld - Doom Castle + id: 476 + area: 0 + type: "Overworld" + teleporter: [0x21, 1] +- name: Overworld - Light Temple + id: 477 + area: 0 + type: "Overworld" + teleporter: [0x22, 1] +- name: Overworld - Mac Ship + id: 478 + area: 0 + type: "Overworld" + teleporter: [0x24, 1] +- name: Overworld - Mac Ship Doom + id: 479 + area: 0 + type: "Overworld" + teleporter: [0x24, 1] +- name: Dummy House - Bed Script + id: 480 + area: 17 + coordinates: [0x28, 0x38] + teleporter: [1, 8] +- name: Dummy House - Entrance + id: 481 + area: 17 + coordinates: [0x29, 0x3B] + teleporter: [0, 10] #None diff --git a/worlds/ffmq/data/rooms.yaml b/worlds/ffmq/data/rooms.yaml new file mode 100644 index 0000000000..e0c2e8d7f9 --- /dev/null +++ b/worlds/ffmq/data/rooms.yaml @@ -0,0 +1,4026 @@ +- name: Overworld + id: 0 + type: "Overworld" + game_objects: [] + links: + - target_room: 220 # To Forest Subregion + access: [] +- name: Subregion Foresta + id: 220 + type: "Subregion" + region: "Foresta" + game_objects: + - name: "Foresta South Battlefield" + object_id: 0x01 + location: "ForestaSouthBattlefield" + location_slot: "ForestaSouthBattlefield" + type: "BattlefieldXp" + access: [] + - name: "Foresta West Battlefield" + object_id: 0x02 + location: "ForestaWestBattlefield" + location_slot: "ForestaWestBattlefield" + type: "BattlefieldItem" + access: [] + - name: "Foresta East Battlefield" + object_id: 0x03 + location: "ForestaEastBattlefield" + location_slot: "ForestaEastBattlefield" + type: "BattlefieldGp" + access: [] + links: + - target_room: 15 # Level Forest + location: "LevelForest" + location_slot: "LevelForest" + entrance: 445 + teleporter: [0x2E, 8] + access: [] + - target_room: 16 # Foresta + location: "Foresta" + location_slot: "Foresta" + entrance: 446 + teleporter: [0x02, 1] + access: [] + - target_room: 24 # Sand Temple + location: "SandTemple" + location_slot: "SandTemple" + entrance: 447 + teleporter: [0x03, 1] + access: [] + - target_room: 25 # Bone Dungeon + location: "BoneDungeon" + location_slot: "BoneDungeon" + entrance: 448 + teleporter: [0x04, 1] + access: [] + - target_room: 3 # Focus Tower Foresta + location: "FocusTowerForesta" + location_slot: "FocusTowerForesta" + entrance: 449 + teleporter: [0x05, 1] + access: [] + - target_room: 221 + access: ["SandCoin"] + - target_room: 224 + access: ["RiverCoin"] + - target_room: 226 + access: ["SunCoin"] +- name: Subregion Aquaria + id: 221 + type: "Subregion" + region: "Aquaria" + game_objects: + - name: "South of Libra Temple Battlefield" + object_id: 0x04 + location: "AquariaBattlefield01" + location_slot: "AquariaBattlefield01" + type: "BattlefieldXp" + access: [] + - name: "East of Libra Temple Battlefield" + object_id: 0x05 + location: "AquariaBattlefield02" + location_slot: "AquariaBattlefield02" + type: "BattlefieldGp" + access: [] + - name: "South of Aquaria Battlefield" + object_id: 0x06 + location: "AquariaBattlefield03" + location_slot: "AquariaBattlefield03" + type: "BattlefieldItem" + access: [] + - name: "South of Wintry Cave Battlefield" + object_id: 0x07 + location: "WintryBattlefield01" + location_slot: "WintryBattlefield01" + type: "BattlefieldXp" + access: [] + - name: "West of Wintry Cave Battlefield" + object_id: 0x08 + location: "WintryBattlefield02" + location_slot: "WintryBattlefield02" + type: "BattlefieldGp" + access: [] + - name: "Ice Pyramid Battlefield" + object_id: 0x09 + location: "PyramidBattlefield01" + location_slot: "PyramidBattlefield01" + type: "BattlefieldXp" + access: [] + links: + - target_room: 10 # Focus Tower Aquaria + location: "FocusTowerAquaria" + location_slot: "FocusTowerAquaria" + entrance: 450 + teleporter: [0x13, 1] + access: [] + - target_room: 39 # Libra Temple + location: "LibraTemple" + location_slot: "LibraTemple" + entrance: 451 + teleporter: [0x07, 1] + access: [] + - target_room: 40 # Aquaria + location: "Aquaria" + location_slot: "Aquaria" + entrance: 452 + teleporter: [0x08, 8] + access: [] + - target_room: 45 # Wintry Cave + location: "WintryCave" + location_slot: "WintryCave" + entrance: 453 + teleporter: [0x0A, 1] + access: [] + - target_room: 52 # Falls Basin + location: "FallsBasin" + location_slot: "FallsBasin" + entrance: 455 + teleporter: [0x0C, 1] + access: [] + - target_room: 54 # Ice Pyramid + location: "IcePyramid" + location_slot: "IcePyramid" + entrance: 456 + teleporter: [0x0D, 1] # Will be switched to a script + access: [] + - target_room: 220 + access: ["SandCoin"] + - target_room: 224 + access: ["SandCoin", "RiverCoin"] + - target_room: 226 + access: ["SandCoin", "SunCoin"] + - target_room: 223 + access: ["SummerAquaria"] +- name: Subregion Life Temple + id: 222 + type: "Subregion" + region: "LifeTemple" + game_objects: [] + links: + - target_room: 51 # Life Temple + location: "LifeTemple" + location_slot: "LifeTemple" + entrance: 454 + teleporter: [0x0B, 1] + access: [] +- name: Subregion Frozen Fields + id: 223 + type: "Subregion" + region: "AquariaFrozenField" + game_objects: + - name: "North of Libra Temple Battlefield" + object_id: 0x0A + location: "LibraBattlefield01" + location_slot: "LibraBattlefield01" + type: "BattlefieldItem" + access: [] + - name: "Aquaria Frozen Field Battlefield" + object_id: 0x0B + location: "LibraBattlefield02" + location_slot: "LibraBattlefield02" + type: "BattlefieldXp" + access: [] + links: + - target_room: 74 # Wintry Temple + location: "WintryTemple" + location_slot: "WintryTemple" + entrance: 458 + teleporter: [0x10, 1] + access: [] + - target_room: 14 # Focus Tower Frozen Strip + location: "FocusTowerFrozen" + location_slot: "FocusTowerFrozen" + entrance: 459 + teleporter: [0x11, 1] + access: [] + - target_room: 221 + access: [] + - target_room: 225 + access: ["SummerAquaria", "DualheadHydra"] +- name: Subregion Fireburg + id: 224 + type: "Subregion" + region: "Fireburg" + game_objects: + - name: "Path to Fireburg Southern Battlefield" + object_id: 0x0C + location: "FireburgBattlefield01" + location_slot: "FireburgBattlefield01" + type: "BattlefieldGp" + access: [] + - name: "Path to Fireburg Central Battlefield" + object_id: 0x0D + location: "FireburgBattlefield02" + location_slot: "FireburgBattlefield02" + type: "BattlefieldItem" + access: [] + - name: "Path to Fireburg Northern Battlefield" + object_id: 0x0E + location: "FireburgBattlefield03" + location_slot: "FireburgBattlefield03" + type: "BattlefieldXp" + access: [] + - name: "Sealed Temple Battlefield" + object_id: 0x0F + location: "MineBattlefield01" + location_slot: "MineBattlefield01" + type: "BattlefieldGp" + access: [] + - name: "Mine Battlefield" + object_id: 0x10 + location: "MineBattlefield02" + location_slot: "MineBattlefield02" + type: "BattlefieldItem" + access: [] + - name: "Boulder Battlefield" + object_id: 0x11 + location: "MineBattlefield03" + location_slot: "MineBattlefield03" + type: "BattlefieldXp" + access: [] + links: + - target_room: 13 # Focus Tower Fireburg + location: "FocusTowerFireburg" + location_slot: "FocusTowerFireburg" + entrance: 460 + teleporter: [0x12, 1] + access: [] + - target_room: 76 # Fireburg + location: "Fireburg" + location_slot: "Fireburg" + entrance: 461 + teleporter: [0x14, 1] + access: [] + - target_room: 84 # Mine + location: "Mine" + location_slot: "Mine" + entrance: 462 + teleporter: [0x15, 1] + access: [] + - target_room: 92 # Sealed Temple + location: "SealedTemple" + location_slot: "SealedTemple" + entrance: 463 + teleporter: [0x16, 1] + access: [] + - target_room: 93 # Volcano + location: "Volcano" + location_slot: "Volcano" + entrance: 464 + teleporter: [0x17, 1] # Also this one / 0x0F, 8 + access: [] + - target_room: 100 # Lava Dome + location: "LavaDome" + location_slot: "LavaDome" + entrance: 465 + teleporter: [0x18, 1] + access: [] + - target_room: 220 + access: ["RiverCoin"] + - target_room: 221 + access: ["SandCoin", "RiverCoin"] + - target_room: 226 + access: ["RiverCoin", "SunCoin"] + - target_room: 225 + access: ["DualheadHydra"] +- name: Subregion Volcano Battlefield + id: 225 + type: "Subregion" + region: "VolcanoBattlefield" + game_objects: + - name: "Volcano Battlefield" + object_id: 0x12 + location: "VolcanoBattlefield01" + location_slot: "VolcanoBattlefield01" + type: "BattlefieldXp" + access: [] + links: + - target_room: 224 + access: ["DualheadHydra"] + - target_room: 223 + access: ["SummerAquaria"] +- name: Subregion Windia + id: 226 + type: "Subregion" + region: "Windia" + game_objects: + - name: "Kaidge Temple Battlefield" + object_id: 0x13 + location: "WindiaBattlefield01" + location_slot: "WindiaBattlefield01" + type: "BattlefieldXp" + access: ["SandCoin", "RiverCoin"] + - name: "South of Windia Battlefield" + object_id: 0x14 + location: "WindiaBattlefield02" + location_slot: "WindiaBattlefield02" + type: "BattlefieldXp" + access: ["SandCoin", "RiverCoin"] + links: + - target_room: 9 # Focus Tower Windia + location: "FocusTowerWindia" + location_slot: "FocusTowerWindia" + entrance: 466 + teleporter: [0x06, 1] + access: [] + - target_room: 123 # Rope Bridge + location: "RopeBridge" + location_slot: "RopeBridge" + entrance: 467 + teleporter: [0x19, 1] + access: [] + - target_room: 124 # Alive Forest + location: "AliveForest" + location_slot: "AliveForest" + entrance: 468 + teleporter: [0x1A, 1] + access: [] + - target_room: 125 # Giant Tree + location: "GiantTree" + location_slot: "GiantTree" + entrance: 469 + teleporter: [0x1B, 1] + access: ["Barred"] + - target_room: 152 # Kaidge Temple + location: "KaidgeTemple" + location_slot: "KaidgeTemple" + entrance: 470 + teleporter: [0x1C, 1] + access: [] + - target_room: 156 # Windia + location: "Windia" + location_slot: "Windia" + entrance: 471 + teleporter: [0x1D, 1] + access: [] + - target_room: 154 # Windhole Temple + location: "WindholeTemple" + location_slot: "WindholeTemple" + entrance: 472 + teleporter: [0x1E, 1] + access: [] + - target_room: 155 # Mount Gale + location: "MountGale" + location_slot: "MountGale" + entrance: 473 + teleporter: [0x1F, 1] + access: [] + - target_room: 166 # Pazuzu Tower + location: "PazuzusTower" + location_slot: "PazuzusTower" + entrance: 474 + teleporter: [0x20, 1] + access: [] + - target_room: 220 + access: ["SunCoin"] + - target_room: 221 + access: ["SandCoin", "SunCoin"] + - target_room: 224 + access: ["RiverCoin", "SunCoin"] + - target_room: 227 + access: ["RainbowBridge"] +- name: Subregion Spencer's Cave + id: 227 + type: "Subregion" + region: "SpencerCave" + game_objects: [] + links: + - target_room: 73 # Spencer's Place + location: "SpencersPlace" + location_slot: "SpencersPlace" + entrance: 457 + teleporter: [0x30, 8] + access: [] + - target_room: 226 + access: ["RainbowBridge"] +- name: Subregion Ship Dock + id: 228 + type: "Subregion" + region: "ShipDock" + game_objects: [] + links: + - target_room: 186 # Ship Dock + location: "ShipDock" + location_slot: "ShipDock" + entrance: 475 + teleporter: [0x3E, 1] + access: [] + - target_room: 229 + access: ["ShipLiberated", "ShipDockAccess"] +- name: Subregion Mac's Ship + id: 229 + type: "Subregion" + region: "MacShip" + game_objects: [] + links: + - target_room: 187 # Mac Ship + location: "MacsShip" + location_slot: "MacsShip" + entrance: 478 + teleporter: [0x24, 1] + access: [] + - target_room: 228 + access: ["ShipLiberated", "ShipDockAccess"] + - target_room: 231 + access: ["ShipLoaned", "ShipDockAccess", "ShipSteeringWheel"] +- name: Subregion Light Temple + id: 230 + type: "Subregion" + region: "LightTemple" + game_objects: [] + links: + - target_room: 185 # Light Temple + location: "LightTemple" + location_slot: "LightTemple" + entrance: 477 + teleporter: [0x23, 1] + access: [] +- name: Subregion Doom Castle + id: 231 + type: "Subregion" + region: "DoomCastle" + game_objects: [] + links: + - target_room: 1 # Doom Castle + location: "DoomCastle" + location_slot: "DoomCastle" + entrance: 476 + teleporter: [0x21, 1] + access: [] + - target_room: 187 # Mac Ship Doom + location: "MacsShipDoom" + location_slot: "MacsShipDoom" + entrance: 479 + teleporter: [0x24, 1] + access: ["Barred"] + - target_room: 229 + access: ["ShipLoaned", "ShipDockAccess", "ShipSteeringWheel"] +- name: Doom Castle - Sand Floor + id: 1 + game_objects: + - name: "Doom Castle B2 - Southeast Chest" + object_id: 0x01 + type: "Chest" + access: ["Bomb"] + - name: "Doom Castle B2 - Bone Ledge Box" + object_id: 0x1E + type: "Box" + access: [] + - name: "Doom Castle B2 - Hook Platform Box" + object_id: 0x1F + type: "Box" + access: ["DragonClaw"] + links: + - target_room: 231 + entrance: 1 + teleporter: [1, 6] + access: [] + - target_room: 5 + entrance: 0 + teleporter: [0, 0] + access: ["DragonClaw", "MegaGrenade"] +- name: Doom Castle - Aero Room + id: 2 + game_objects: + - name: "Doom Castle B2 - Sun Door Chest" + object_id: 0x00 + type: "Chest" + access: [] + links: + - target_room: 4 + entrance: 2 + teleporter: [1, 0] + access: [] +- name: Focus Tower B1 - Main Loop + id: 3 + game_objects: [] + links: + - target_room: 220 + entrance: 3 + teleporter: [2, 6] + access: [] + - target_room: 6 + entrance: 4 + teleporter: [4, 0] + access: [] +- name: Focus Tower B1 - Aero Corridor + id: 4 + game_objects: [] + links: + - target_room: 9 + entrance: 5 + teleporter: [5, 0] + access: [] + - target_room: 2 + entrance: 6 + teleporter: [8, 0] + access: [] +- name: Focus Tower B1 - Inner Loop + id: 5 + game_objects: [] + links: + - target_room: 1 + entrance: 8 + teleporter: [7, 0] + access: [] + - target_room: 201 + entrance: 7 + teleporter: [6, 0] + access: [] +- name: Focus Tower 1F Main Lobby + id: 6 + game_objects: + - name: "Focus Tower 1F - Main Lobby Box" + object_id: 0x21 + type: "Box" + access: [] + links: + - target_room: 3 + entrance: 11 + teleporter: [11, 0] + access: [] + - target_room: 7 + access: ["SandCoin"] + - target_room: 8 + access: ["RiverCoin"] + - target_room: 9 + access: ["SunCoin"] +- name: Focus Tower 1F SandCoin Room + id: 7 + game_objects: [] + links: + - target_room: 6 + access: ["SandCoin"] + - target_room: 10 + entrance: 10 + teleporter: [10, 0] + access: [] +- name: Focus Tower 1F RiverCoin Room + id: 8 + game_objects: [] + links: + - target_room: 6 + access: ["RiverCoin"] + - target_room: 11 + entrance: 14 + teleporter: [14, 0] + access: [] +- name: Focus Tower 1F SunCoin Room + id: 9 + game_objects: [] + links: + - target_room: 6 + access: ["SunCoin"] + - target_room: 4 + entrance: 12 + teleporter: [12, 0] + access: [] + - target_room: 226 + entrance: 9 + teleporter: [3, 6] + access: [] +- name: Focus Tower 1F SkyCoin Room + id: 201 + game_objects: [] + links: + - target_room: 195 + entrance: 13 + teleporter: [13, 0] + access: ["SkyCoin", "FlamerusRex", "IceGolem", "DualheadHydra", "Pazuzu"] + - target_room: 5 + entrance: 15 + teleporter: [15, 0] + access: [] +- name: Focus Tower 2F - Sand Coin Passage + id: 10 + game_objects: + - name: "Focus Tower 2F - Sand Door Chest" + object_id: 0x03 + type: "Chest" + access: [] + links: + - target_room: 221 + entrance: 16 + teleporter: [4, 6] + access: [] + - target_room: 7 + entrance: 17 + teleporter: [17, 0] + access: [] +- name: Focus Tower 2F - River Coin Passage + id: 11 + game_objects: [] + links: + - target_room: 8 + entrance: 18 + teleporter: [18, 0] + access: [] + - target_room: 13 + entrance: 19 + teleporter: [20, 0] + access: [] +- name: Focus Tower 2F - Venus Chest Room + id: 12 + game_objects: + - name: "Focus Tower 2F - Back Door Chest" + object_id: 0x02 + type: "Chest" + access: [] + - name: "Focus Tower 2F - Venus Chest" + object_id: 9 + type: "NPC" + access: ["Bomb", "VenusKey"] + links: + - target_room: 14 + entrance: 20 + teleporter: [19, 0] + access: [] +- name: Focus Tower 3F - Lower Floor + id: 13 + game_objects: + - name: "Focus Tower 3F - River Door Box" + object_id: 0x22 + type: "Box" + access: [] + links: + - target_room: 224 + entrance: 22 + teleporter: [6, 6] + access: [] + - target_room: 11 + entrance: 23 + teleporter: [24, 0] + access: [] +- name: Focus Tower 3F - Upper Floor + id: 14 + game_objects: [] + links: + - target_room: 223 + entrance: 24 + teleporter: [5, 6] + access: [] + - target_room: 12 + entrance: 25 + teleporter: [23, 0] + access: [] +- name: Level Forest + id: 15 + game_objects: + - name: "Level Forest - Northwest Box" + object_id: 0x28 + type: "Box" + access: ["Axe"] + - name: "Level Forest - Northeast Box" + object_id: 0x29 + type: "Box" + access: ["Axe"] + - name: "Level Forest - Middle Box" + object_id: 0x2A + type: "Box" + access: [] + - name: "Level Forest - Southwest Box" + object_id: 0x2B + type: "Box" + access: ["Axe"] + - name: "Level Forest - Southeast Box" + object_id: 0x2C + type: "Box" + access: ["Axe"] + - name: "Minotaur" + object_id: 0 + type: "Trigger" + on_trigger: ["Minotaur"] + access: ["Kaeli1"] + - name: "Level Forest - Old Man" + object_id: 0 + type: "NPC" + access: [] + - name: "Level Forest - Kaeli" + object_id: 1 + type: "NPC" + access: ["Kaeli1", "Minotaur"] + links: + - target_room: 220 + entrance: 28 + teleporter: [25, 0] + access: [] +- name: Foresta + id: 16 + game_objects: + - name: "Foresta - Outside Box" + object_id: 0x2D + type: "Box" + access: ["Axe"] + links: + - target_room: 220 + entrance: 38 + teleporter: [31, 0] + access: [] + - target_room: 17 + entrance: 44 + teleporter: [0, 5] + access: [] + - target_room: 18 + entrance: 42 + teleporter: [32, 4] + access: [] + - target_room: 19 + entrance: 43 + teleporter: [33, 0] + access: [] + - target_room: 20 + entrance: 45 + teleporter: [1, 5] + access: [] +- name: Kaeli's House + id: 17 + game_objects: + - name: "Foresta - Kaeli's House Box" + object_id: 0x2E + type: "Box" + access: [] + - name: "Kaeli Companion" + object_id: 0 + type: "Trigger" + on_trigger: ["Kaeli1"] + access: ["TreeWither"] + - name: "Kaeli 2" + object_id: 0 + type: "Trigger" + on_trigger: ["Kaeli2"] + access: ["Kaeli1", "Minotaur", "Elixir"] + links: + - target_room: 16 + entrance: 46 + teleporter: [86, 3] + access: [] +- name: Foresta Houses - Old Man's House Main + id: 18 + game_objects: [] + links: + - target_room: 19 + access: ["BarrelPushed"] + - target_room: 16 + entrance: 47 + teleporter: [34, 0] + access: [] +- name: Foresta Houses - Old Man's House Back + id: 19 + game_objects: + - name: "Foresta - Old Man House Chest" + object_id: 0x05 + type: "Chest" + access: [] + - name: "Old Man Barrel" + object_id: 0 + type: "Trigger" + on_trigger: ["BarrelPushed"] + access: [] + links: + - target_room: 18 + access: ["BarrelPushed"] + - target_room: 16 + entrance: 48 + teleporter: [35, 0] + access: [] +- name: Foresta Houses - Rest House + id: 20 + game_objects: + - name: "Foresta - Rest House Box" + object_id: 0x2F + type: "Box" + access: [] + links: + - target_room: 16 + entrance: 50 + teleporter: [87, 3] + access: [] +- name: Libra Treehouse + id: 21 + game_objects: + - name: "Alive Forest - Libra Treehouse Box" + object_id: 0x32 + type: "Box" + access: [] + links: + - target_room: 124 + entrance: 51 + teleporter: [67, 8] + access: ["LibraCrest"] +- name: Gemini Treehouse + id: 22 + game_objects: + - name: "Alive Forest - Gemini Treehouse Box" + object_id: 0x33 + type: "Box" + access: [] + links: + - target_room: 124 + entrance: 52 + teleporter: [68, 8] + access: ["GeminiCrest"] +- name: Mobius Treehouse + id: 23 + game_objects: + - name: "Alive Forest - Mobius Treehouse West Box" + object_id: 0x30 + type: "Box" + access: [] + - name: "Alive Forest - Mobius Treehouse East Box" + object_id: 0x31 + type: "Box" + access: [] + links: + - target_room: 124 + entrance: 53 + teleporter: [69, 8] + access: ["MobiusCrest"] +- name: Sand Temple + id: 24 + game_objects: + - name: "Tristam Companion" + object_id: 0 + type: "Trigger" + on_trigger: ["Tristam"] + access: [] + links: + - target_room: 220 + entrance: 54 + teleporter: [36, 0] + access: [] +- name: Bone Dungeon 1F + id: 25 + game_objects: + - name: "Bone Dungeon 1F - Entrance Room West Box" + object_id: 0x35 + type: "Box" + access: [] + - name: "Bone Dungeon 1F - Entrance Room Middle Box" + object_id: 0x36 + type: "Box" + access: [] + - name: "Bone Dungeon 1F - Entrance Room East Box" + object_id: 0x37 + type: "Box" + access: [] + links: + - target_room: 220 + entrance: 55 + teleporter: [37, 0] + access: [] + - target_room: 26 + entrance: 56 + teleporter: [2, 2] + access: [] +- name: Bone Dungeon B1 - Waterway + id: 26 + game_objects: + - name: "Bone Dungeon B1 - Skull Chest" + object_id: 0x06 + type: "Chest" + access: ["Bomb"] + - name: "Bone Dungeon B1 - Tristam" + object_id: 2 + type: "NPC" + access: ["Tristam"] + - name: "Tristam Bone Dungeon Item Given" + object_id: 0 + type: "Trigger" + on_trigger: ["TristamBoneItemGiven"] + access: ["Tristam"] + links: + - target_room: 25 + entrance: 59 + teleporter: [88, 3] + access: [] + - target_room: 28 + entrance: 57 + teleporter: [3, 2] + access: ["Bomb"] +- name: Bone Dungeon B1 - Checker Room + id: 28 + game_objects: + - name: "Bone Dungeon B1 - Checker Room Box" + object_id: 0x38 + type: "Box" + access: ["Bomb"] + links: + - target_room: 26 + entrance: 61 + teleporter: [89, 3] + access: [] + - target_room: 30 + entrance: 60 + teleporter: [4, 2] + access: [] +- name: Bone Dungeon B1 - Hidden Room + id: 29 + game_objects: + - name: "Bone Dungeon B1 - Ribcage Waterway Box" + object_id: 0x39 + type: "Box" + access: [] + links: + - target_room: 31 + entrance: 62 + teleporter: [91, 3] + access: [] +- name: Bone Dungeon B2 - Exploding Skull Room - First Room + id: 30 + game_objects: + - name: "Bone Dungeon B2 - Spines Room Alcove Box" + object_id: 0x3B + type: "Box" + access: [] + - name: "Long Spine" + object_id: 0 + type: "Trigger" + on_trigger: ["LongSpineBombed"] + access: ["Bomb"] + links: + - target_room: 28 + entrance: 65 + teleporter: [90, 3] + access: [] + - target_room: 31 + access: ["LongSpineBombed"] +- name: Bone Dungeon B2 - Exploding Skull Room - Second Room + id: 31 + game_objects: + - name: "Bone Dungeon B2 - Spines Room Looped Hallway Box" + object_id: 0x3A + type: "Box" + access: [] + - name: "Short Spine" + object_id: 0 + type: "Trigger" + on_trigger: ["ShortSpineBombed"] + access: ["Bomb"] + links: + - target_room: 29 + entrance: 63 + teleporter: [5, 2] + access: ["LongSpineBombed"] + - target_room: 32 + access: ["ShortSpineBombed"] + - target_room: 30 + access: ["LongSpineBombed"] +- name: Bone Dungeon B2 - Exploding Skull Room - Third Room + id: 32 + game_objects: [] + links: + - target_room: 35 + entrance: 64 + teleporter: [6, 2] + access: [] + - target_room: 31 + access: ["ShortSpineBombed"] +- name: Bone Dungeon B2 - Box Room + id: 33 + game_objects: + - name: "Bone Dungeon B2 - Lone Room Box" + object_id: 0x3D + type: "Box" + access: [] + links: + - target_room: 36 + entrance: 66 + teleporter: [93, 3] + access: [] +- name: Bone Dungeon B2 - Quake Room + id: 34 + game_objects: + - name: "Bone Dungeon B2 - Penultimate Room Chest" + object_id: 0x07 + type: "Chest" + access: [] + links: + - target_room: 37 + entrance: 67 + teleporter: [94, 3] + access: [] +- name: Bone Dungeon B2 - Two Skulls Room - First Room + id: 35 + game_objects: + - name: "Bone Dungeon B2 - Two Skulls Room Box" + object_id: 0x3C + type: "Box" + access: [] + - name: "Skull 1" + object_id: 0 + type: "Trigger" + on_trigger: ["Skull1Bombed"] + access: ["Bomb"] + links: + - target_room: 32 + entrance: 71 + teleporter: [92, 3] + access: [] + - target_room: 36 + access: ["Skull1Bombed"] +- name: Bone Dungeon B2 - Two Skulls Room - Second Room + id: 36 + game_objects: + - name: "Skull 2" + object_id: 0 + type: "Trigger" + on_trigger: ["Skull2Bombed"] + access: ["Bomb"] + links: + - target_room: 33 + entrance: 68 + teleporter: [7, 2] + access: [] + - target_room: 37 + access: ["Skull2Bombed"] + - target_room: 35 + access: ["Skull1Bombed"] +- name: Bone Dungeon B2 - Two Skulls Room - Third Room + id: 37 + game_objects: [] + links: + - target_room: 34 + entrance: 69 + teleporter: [8, 2] + access: [] + - target_room: 38 + entrance: 70 + teleporter: [9, 2] + access: ["Bomb"] + - target_room: 36 + access: ["Skull2Bombed"] +- name: Bone Dungeon B2 - Boss Room + id: 38 + game_objects: + - name: "Bone Dungeon B2 - North Box" + object_id: 0x3E + type: "Box" + access: [] + - name: "Bone Dungeon B2 - South Box" + object_id: 0x3F + type: "Box" + access: [] + - name: "Bone Dungeon B2 - Flamerus Rex Chest" + object_id: 0x08 + type: "Chest" + access: [] + - name: "Bone Dungeon B2 - Tristam's Treasure Chest" + object_id: 0x04 + type: "Chest" + access: [] + - name: "Flamerus Rex" + object_id: 0 + type: "Trigger" + on_trigger: ["FlamerusRex"] + access: [] + links: + - target_room: 37 + entrance: 74 + teleporter: [95, 3] + access: [] +- name: Libra Temple + id: 39 + game_objects: + - name: "Libra Temple - Box" + object_id: 0x40 + type: "Box" + access: [] + - name: "Phoebe Companion" + object_id: 0 + type: "Trigger" + on_trigger: ["Phoebe1"] + access: [] + links: + - target_room: 221 + entrance: 75 + teleporter: [13, 6] + access: [] + - target_room: 51 + entrance: 76 + teleporter: [59, 8] + access: ["LibraCrest"] +- name: Aquaria + id: 40 + game_objects: + - name: "Summer Aquaria" + object_id: 0 + type: "Trigger" + on_trigger: ["SummerAquaria"] + access: ["WakeWater"] + links: + - target_room: 221 + entrance: 77 + teleporter: [8, 6] + access: [] + - target_room: 41 + entrance: 81 + teleporter: [10, 5] + access: [] + - target_room: 42 + entrance: 82 + teleporter: [44, 4] + access: [] + - target_room: 44 + entrance: 83 + teleporter: [11, 5] + access: [] + - target_room: 71 + entrance: 89 + teleporter: [42, 0] + access: ["SummerAquaria"] + - target_room: 71 + entrance: 90 + teleporter: [43, 0] + access: ["SummerAquaria"] +- name: Phoebe's House + id: 41 + game_objects: + - name: "Aquaria - Phoebe's House Chest" + object_id: 0x41 + type: "Box" + access: [] + links: + - target_room: 40 + entrance: 93 + teleporter: [5, 8] + access: [] +- name: Aquaria Vendor House + id: 42 + game_objects: + - name: "Aquaria - Vendor" + object_id: 4 + type: "NPC" + access: [] + - name: "Aquaria - Vendor House Box" + object_id: 0x42 + type: "Box" + access: [] + links: + - target_room: 40 + entrance: 94 + teleporter: [40, 8] + access: [] + - target_room: 43 + entrance: 95 + teleporter: [47, 0] + access: [] +- name: Aquaria Gemini Room + id: 43 + game_objects: [] + links: + - target_room: 42 + entrance: 97 + teleporter: [48, 0] + access: [] + - target_room: 81 + entrance: 96 + teleporter: [72, 8] + access: ["GeminiCrest"] +- name: Aquaria INN + id: 44 + game_objects: [] + links: + - target_room: 40 + entrance: 98 + teleporter: [75, 8] + access: [] +- name: Wintry Cave 1F - East Ledge + id: 45 + game_objects: + - name: "Wintry Cave 1F - North Box" + object_id: 0x43 + type: "Box" + access: [] + - name: "Wintry Cave 1F - Entrance Box" + object_id: 0x46 + type: "Box" + access: [] + - name: "Wintry Cave 1F - Slippery Cliff Box" + object_id: 0x44 + type: "Box" + access: ["Claw"] + - name: "Wintry Cave 1F - Phoebe" + object_id: 5 + type: "NPC" + access: ["Phoebe1"] + links: + - target_room: 221 + entrance: 99 + teleporter: [49, 0] + access: [] + - target_room: 49 + entrance: 100 + teleporter: [14, 2] + access: ["Bomb"] + - target_room: 46 + access: ["Claw"] +- name: Wintry Cave 1F - Central Space + id: 46 + game_objects: + - name: "Wintry Cave 1F - Scenic Overlook Box" + object_id: 0x45 + type: "Box" + access: ["Claw"] + links: + - target_room: 45 + access: ["Claw"] + - target_room: 47 + access: ["Claw"] +- name: Wintry Cave 1F - West Ledge + id: 47 + game_objects: [] + links: + - target_room: 48 + entrance: 101 + teleporter: [15, 2] + access: ["Bomb"] + - target_room: 46 + access: ["Claw"] +- name: Wintry Cave 2F + id: 48 + game_objects: + - name: "Wintry Cave 2F - West Left Box" + object_id: 0x47 + type: "Box" + access: [] + - name: "Wintry Cave 2F - West Right Box" + object_id: 0x48 + type: "Box" + access: [] + - name: "Wintry Cave 2F - East Left Box" + object_id: 0x49 + type: "Box" + access: [] + - name: "Wintry Cave 2F - East Right Box" + object_id: 0x4A + type: "Box" + access: [] + links: + - target_room: 47 + entrance: 104 + teleporter: [97, 3] + access: [] + - target_room: 50 + entrance: 103 + teleporter: [50, 0] + access: [] +- name: Wintry Cave 3F Top + id: 49 + game_objects: + - name: "Wintry Cave 3F - West Box" + object_id: 0x4B + type: "Box" + access: [] + - name: "Wintry Cave 3F - East Box" + object_id: 0x4C + type: "Box" + access: [] + links: + - target_room: 45 + entrance: 105 + teleporter: [96, 3] + access: [] +- name: Wintry Cave 3F Bottom + id: 50 + game_objects: + - name: "Wintry Cave 3F - Squidite Chest" + object_id: 0x09 + type: "Chest" + access: ["Phanquid"] + - name: "Phanquid" + object_id: 0 + type: "Trigger" + on_trigger: ["Phanquid"] + access: [] + - name: "Wintry Cave 3F - Before Boss Box" + object_id: 0x4D + type: "Box" + access: [] + links: + - target_room: 48 + entrance: 106 + teleporter: [51, 0] + access: [] +- name: Life Temple + id: 51 + game_objects: + - name: "Life Temple - Box" + object_id: 0x4E + type: "Box" + access: [] + - name: "Life Temple - Mysterious Man" + object_id: 6 + type: "NPC" + access: [] + links: + - target_room: 222 + entrance: 107 + teleporter: [14, 6] + access: [] + - target_room: 39 + entrance: 108 + teleporter: [60, 8] + access: ["LibraCrest"] +- name: Fall Basin + id: 52 + game_objects: + - name: "Falls Basin - Snow Crab Chest" + object_id: 0x0A + type: "Chest" + access: ["FreezerCrab"] + - name: "Freezer Crab" + object_id: 0 + type: "Trigger" + on_trigger: ["FreezerCrab"] + access: [] + - name: "Falls Basin - Box" + object_id: 0x4F + type: "Box" + access: [] + links: + - target_room: 221 + entrance: 111 + teleporter: [53, 0] + access: [] +- name: Ice Pyramid B1 Taunt Room + id: 53 + game_objects: + - name: "Ice Pyramid B1 - Chest" + object_id: 0x0B + type: "Chest" + access: [] + - name: "Ice Pyramid B1 - West Box" + object_id: 0x50 + type: "Box" + access: [] + - name: "Ice Pyramid B1 - North Box" + object_id: 0x51 + type: "Box" + access: [] + - name: "Ice Pyramid B1 - East Box" + object_id: 0x52 + type: "Box" + access: [] + links: + - target_room: 68 + entrance: 113 + teleporter: [55, 0] + access: [] +- name: Ice Pyramid 1F Maze Lobby + id: 54 + game_objects: + - name: "Ice Pyramid 1F Statue" + object_id: 0 + type: "Trigger" + on_trigger: ["IcePyramid1FStatue"] + access: ["Sword"] + links: + - target_room: 221 + entrance: 114 + teleporter: [56, 0] + access: [] + - target_room: 55 + access: ["IcePyramid1FStatue"] +- name: Ice Pyramid 1F Maze + id: 55 + game_objects: + - name: "Ice Pyramid 1F - East Alcove Chest" + object_id: 0x0D + type: "Chest" + access: [] + - name: "Ice Pyramid 1F - Sandwiched Alcove Box" + object_id: 0x53 + type: "Box" + access: [] + - name: "Ice Pyramid 1F - Southwest Left Box" + object_id: 0x54 + type: "Box" + access: [] + - name: "Ice Pyramid 1F - Southwest Right Box" + object_id: 0x55 + type: "Box" + access: [] + links: + - target_room: 56 + entrance: 116 + teleporter: [57, 0] + access: [] + - target_room: 57 + entrance: 117 + teleporter: [58, 0] + access: [] + - target_room: 58 + entrance: 118 + teleporter: [59, 0] + access: [] + - target_room: 59 + entrance: 119 + teleporter: [60, 0] + access: [] + - target_room: 60 + entrance: 120 + teleporter: [61, 0] + access: [] + - target_room: 54 + access: ["IcePyramid1FStatue"] +- name: Ice Pyramid 2F South Tiled Room + id: 56 + game_objects: + - name: "Ice Pyramid 2F - South Side Glass Door Box" + object_id: 0x57 + type: "Box" + access: ["Sword"] + - name: "Ice Pyramid 2F - South Side East Box" + object_id: 0x5B + type: "Box" + access: [] + links: + - target_room: 55 + entrance: 122 + teleporter: [62, 0] + access: [] + - target_room: 61 + entrance: 123 + teleporter: [67, 0] + access: [] +- name: Ice Pyramid 2F West Room + id: 57 + game_objects: + - name: "Ice Pyramid 2F - Northwest Room Box" + object_id: 0x5A + type: "Box" + access: [] + links: + - target_room: 55 + entrance: 124 + teleporter: [63, 0] + access: [] +- name: Ice Pyramid 2F Center Room + id: 58 + game_objects: + - name: "Ice Pyramid 2F - Center Room Box" + object_id: 0x56 + type: "Box" + access: [] + links: + - target_room: 55 + entrance: 125 + teleporter: [64, 0] + access: [] +- name: Ice Pyramid 2F Small North Room + id: 59 + game_objects: + - name: "Ice Pyramid 2F - North Room Glass Door Box" + object_id: 0x58 + type: "Box" + access: ["Sword"] + links: + - target_room: 55 + entrance: 126 + teleporter: [65, 0] + access: [] +- name: Ice Pyramid 2F North Corridor + id: 60 + game_objects: + - name: "Ice Pyramid 2F - North Corridor Glass Door Box" + object_id: 0x59 + type: "Box" + access: ["Sword"] + links: + - target_room: 55 + entrance: 127 + teleporter: [66, 0] + access: [] + - target_room: 62 + entrance: 128 + teleporter: [68, 0] + access: [] +- name: Ice Pyramid 3F Two Boxes Room + id: 61 + game_objects: + - name: "Ice Pyramid 3F - Staircase Dead End Left Box" + object_id: 0x5E + type: "Box" + access: [] + - name: "Ice Pyramid 3F - Staircase Dead End Right Box" + object_id: 0x5F + type: "Box" + access: [] + links: + - target_room: 56 + entrance: 129 + teleporter: [69, 0] + access: [] +- name: Ice Pyramid 3F Main Loop + id: 62 + game_objects: + - name: "Ice Pyramid 3F - Inner Room North Box" + object_id: 0x5C + type: "Box" + access: [] + - name: "Ice Pyramid 3F - Inner Room South Box" + object_id: 0x5D + type: "Box" + access: [] + - name: "Ice Pyramid 3F - East Alcove Box" + object_id: 0x60 + type: "Box" + access: [] + - name: "Ice Pyramid 3F - Leapfrog Box" + object_id: 0x61 + type: "Box" + access: [] + - name: "Ice Pyramid 3F Statue" + object_id: 0 + type: "Trigger" + on_trigger: ["IcePyramid3FStatue"] + access: ["Sword"] + links: + - target_room: 60 + entrance: 130 + teleporter: [70, 0] + access: [] + - target_room: 63 + access: ["IcePyramid3FStatue"] +- name: Ice Pyramid 3F Blocked Room + id: 63 + game_objects: [] + links: + - target_room: 64 + entrance: 131 + teleporter: [71, 0] + access: [] + - target_room: 62 + access: ["IcePyramid3FStatue"] +- name: Ice Pyramid 4F Main Loop + id: 64 + game_objects: [] + links: + - target_room: 66 + entrance: 133 + teleporter: [73, 0] + access: [] + - target_room: 63 + entrance: 132 + teleporter: [72, 0] + access: [] + - target_room: 65 + access: ["IcePyramid4FStatue"] +- name: Ice Pyramid 4F Treasure Room + id: 65 + game_objects: + - name: "Ice Pyramid 4F - Chest" + object_id: 0x0C + type: "Chest" + access: [] + - name: "Ice Pyramid 4F - Northwest Box" + object_id: 0x62 + type: "Box" + access: [] + - name: "Ice Pyramid 4F - West Left Box" + object_id: 0x63 + type: "Box" + access: [] + - name: "Ice Pyramid 4F - West Right Box" + object_id: 0x64 + type: "Box" + access: [] + - name: "Ice Pyramid 4F - South Left Box" + object_id: 0x65 + type: "Box" + access: [] + - name: "Ice Pyramid 4F - South Right Box" + object_id: 0x66 + type: "Box" + access: [] + - name: "Ice Pyramid 4F - East Left Box" + object_id: 0x67 + type: "Box" + access: [] + - name: "Ice Pyramid 4F - East Right Box" + object_id: 0x68 + type: "Box" + access: [] + - name: "Ice Pyramid 4F Statue" + object_id: 0 + type: "Trigger" + on_trigger: ["IcePyramid4FStatue"] + access: ["Sword"] + links: + - target_room: 64 + access: ["IcePyramid4FStatue"] +- name: Ice Pyramid 5F Leap of Faith Room + id: 66 + game_objects: + - name: "Ice Pyramid 5F - Glass Door Left Box" + object_id: 0x69 + type: "Box" + access: ["IcePyramid5FStatue"] + - name: "Ice Pyramid 5F - West Ledge Box" + object_id: 0x6A + type: "Box" + access: [] + - name: "Ice Pyramid 5F - South Shelf Box" + object_id: 0x6B + type: "Box" + access: [] + - name: "Ice Pyramid 5F - South Leapfrog Box" + object_id: 0x6C + type: "Box" + access: [] + - name: "Ice Pyramid 5F - Glass Door Right Box" + object_id: 0x6D + type: "Box" + access: ["IcePyramid5FStatue"] + - name: "Ice Pyramid 5F - North Box" + object_id: 0x6E + type: "Box" + access: [] + links: + - target_room: 64 + entrance: 134 + teleporter: [74, 0] + access: [] + - target_room: 65 + access: [] + - target_room: 53 + access: ["Bomb", "Claw", "Sword"] +- name: Ice Pyramid 5F Stairs to Ice Golem + id: 67 + game_objects: + - name: "Ice Pyramid 5F Statue" + object_id: 0 + type: "Trigger" + on_trigger: ["IcePyramid5FStatue"] + access: ["Sword"] + links: + - target_room: 69 + entrance: 137 + teleporter: [76, 0] + access: [] + - target_room: 65 + access: [] + - target_room: 70 + entrance: 136 + teleporter: [75, 0] + access: [] +- name: Ice Pyramid Climbing Wall Room Lower Space + id: 68 + game_objects: [] + links: + - target_room: 53 + entrance: 139 + teleporter: [78, 0] + access: [] + - target_room: 69 + access: ["Claw"] +- name: Ice Pyramid Climbing Wall Room Upper Space + id: 69 + game_objects: [] + links: + - target_room: 67 + entrance: 140 + teleporter: [79, 0] + access: [] + - target_room: 68 + access: ["Claw"] +- name: Ice Pyramid Ice Golem Room + id: 70 + game_objects: + - name: "Ice Pyramid 6F - Ice Golem Chest" + object_id: 0x0E + type: "Chest" + access: ["IceGolem"] + - name: "Ice Golem" + object_id: 0 + type: "Trigger" + on_trigger: ["IceGolem"] + access: [] + links: + - target_room: 67 + entrance: 141 + teleporter: [80, 0] + access: [] + - target_room: 66 + access: [] +- name: Spencer Waterfall + id: 71 + game_objects: [] + links: + - target_room: 72 + entrance: 143 + teleporter: [81, 0] + access: [] + - target_room: 40 + entrance: 145 + teleporter: [82, 0] + access: [] + - target_room: 40 + entrance: 148 + teleporter: [83, 0] + access: [] +- name: Spencer Cave Normal Main + id: 72 + game_objects: + - name: "Spencer's Cave - Box" + object_id: 0x6F + type: "Box" + access: ["Claw"] + - name: "Spencer's Cave - Spencer" + object_id: 8 + type: "NPC" + access: [] + - name: "Spencer's Cave - Locked Chest" + object_id: 13 + type: "NPC" + access: ["VenusKey"] + links: + - target_room: 71 + entrance: 150 + teleporter: [85, 0] + access: [] +- name: Spencer Cave Normal South Ledge + id: 73 + game_objects: + - name: "Collapse Spencer's Cave" + object_id: 0 + type: "Trigger" + on_trigger: ["ShipLiberated"] + access: ["MegaGrenade"] + links: + - target_room: 227 + entrance: 151 + teleporter: [7, 6] + access: [] + - target_room: 203 + access: ["MegaGrenade"] +# - target_room: 72 # access to spencer? +# access: ["MegaGrenade"] +- name: Spencer Cave Caved In Main Loop + id: 203 + game_objects: [] + links: + - target_room: 73 + access: [] + - target_room: 207 + entrance: 156 + teleporter: [36, 8] + access: ["MobiusCrest"] + - target_room: 204 + access: ["Claw"] + - target_room: 205 + access: ["Bomb"] +- name: Spencer Cave Caved In Waters + id: 204 + game_objects: + - name: "Bomb Libra Block" + object_id: 0 + type: "Trigger" + on_trigger: ["SpencerCaveLibraBlockBombed"] + access: ["MegaGrenade", "Claw"] + links: + - target_room: 203 + access: ["Claw"] +- name: Spencer Cave Caved In Libra Nook + id: 205 + game_objects: [] + links: + - target_room: 206 + entrance: 153 + teleporter: [33, 8] + access: ["LibraCrest"] +- name: Spencer Cave Caved In Libra Corridor + id: 206 + game_objects: [] + links: + - target_room: 205 + entrance: 154 + teleporter: [34, 8] + access: ["LibraCrest"] + - target_room: 207 + access: ["SpencerCaveLibraBlockBombed"] +- name: Spencer Cave Caved In Mobius Chest + id: 207 + game_objects: + - name: "Spencer's Cave - Mobius Chest" + object_id: 0x0F + type: "Chest" + access: [] + links: + - target_room: 203 + entrance: 155 + teleporter: [35, 8] + access: ["MobiusCrest"] + - target_room: 206 + access: ["Bomb"] +- name: Wintry Temple Outer Room + id: 74 + game_objects: [] + links: + - target_room: 223 + entrance: 157 + teleporter: [15, 6] + access: [] +- name: Wintry Temple Inner Room + id: 75 + game_objects: + - name: "Wintry Temple - West Box" + object_id: 0x70 + type: "Box" + access: [] + - name: "Wintry Temple - North Box" + object_id: 0x71 + type: "Box" + access: [] + links: + - target_room: 92 + entrance: 158 + teleporter: [62, 8] + access: ["GeminiCrest"] +- name: Fireburg Upper Plaza + id: 76 + game_objects: [] + links: + - target_room: 224 + entrance: 159 + teleporter: [9, 6] + access: [] + - target_room: 80 + entrance: 163 + teleporter: [91, 0] + access: [] + - target_room: 77 + entrance: 164 + teleporter: [98, 8] # original value [16, 2] + access: [] + - target_room: 82 + entrance: 165 + teleporter: [96, 8] # original value [17, 2] + access: [] + - target_room: 208 + access: ["Claw"] +- name: Fireburg Lower Plaza + id: 208 + game_objects: + - name: "Fireburg - Hidden Tunnel Box" + object_id: 0x74 + type: "Box" + access: [] + links: + - target_room: 76 + access: ["Claw"] + - target_room: 78 + entrance: 166 + teleporter: [11, 8] + access: ["MultiKey"] +- name: Reuben's House + id: 77 + game_objects: + - name: "Fireburg - Reuben's House Arion" + object_id: 14 + type: "NPC" + access: ["ReubenDadSaved"] + - name: "Reuben Companion" + object_id: 0 + type: "Trigger" + on_trigger: ["Reuben1"] + access: [] + - name: "Fireburg - Reuben's House Box" + object_id: 0x75 + type: "Box" + access: [] + links: + - target_room: 76 + entrance: 167 + teleporter: [98, 3] + access: [] +- name: GrenadeMan's House + id: 78 + game_objects: + - name: "Fireburg - Locked House Man" + object_id: 12 + type: "NPC" + access: [] + links: + - target_room: 208 + entrance: 168 + teleporter: [9, 8] + access: ["MultiKey"] + - target_room: 79 + entrance: 169 + teleporter: [93, 0] + access: [] +- name: GrenadeMan's Mobius Room + id: 79 + game_objects: [] + links: + - target_room: 78 + entrance: 170 + teleporter: [94, 0] + access: [] + - target_room: 161 + entrance: 171 + teleporter: [54, 8] + access: ["MobiusCrest"] +- name: Fireburg Vendor House + id: 80 + game_objects: + - name: "Fireburg - Vendor" + object_id: 11 + type: "NPC" + access: [] + links: + - target_room: 76 + entrance: 172 + teleporter: [95, 0] + access: [] + - target_room: 81 + entrance: 173 + teleporter: [96, 0] + access: [] +- name: Fireburg Gemini Room + id: 81 + game_objects: [] + links: + - target_room: 80 + entrance: 174 + teleporter: [97, 0] + access: [] + - target_room: 43 + entrance: 175 + teleporter: [45, 8] + access: ["GeminiCrest"] +- name: Fireburg Hotel Lobby + id: 82 + game_objects: + - name: "Fireburg - Tristam" + object_id: 10 + type: "NPC" + access: ["Tristam", "TristamBoneItemGiven"] + links: + - target_room: 76 + entrance: 177 + teleporter: [99, 3] + access: [] + - target_room: 83 + entrance: 176 + teleporter: [213, 0] + access: [] +- name: Fireburg Hotel Beds + id: 83 + game_objects: [] + links: + - target_room: 82 + entrance: 178 + teleporter: [214, 0] + access: [] +- name: Mine Exterior North West Platforms + id: 84 + game_objects: [] + links: + - target_room: 224 + entrance: 179 + teleporter: [98, 0] + access: [] + - target_room: 88 + entrance: 181 + teleporter: [20, 2] + access: ["Bomb"] + - target_room: 85 + access: ["Claw"] + - target_room: 86 + access: ["Claw"] + - target_room: 87 + access: ["Claw"] +- name: Mine Exterior Central Ledge + id: 85 + game_objects: [] + links: + - target_room: 90 + entrance: 183 + teleporter: [22, 2] + access: ["Bomb"] + - target_room: 84 + access: ["Claw"] +- name: Mine Exterior North Ledge + id: 86 + game_objects: [] + links: + - target_room: 89 + entrance: 182 + teleporter: [21, 2] + access: ["Bomb"] + - target_room: 85 + access: ["Claw"] +- name: Mine Exterior South East Platforms + id: 87 + game_objects: + - name: "Jinn" + object_id: 0 + type: "Trigger" + on_trigger: ["Jinn"] + access: [] + links: + - target_room: 91 + entrance: 180 + teleporter: [99, 0] + access: ["Jinn"] + - target_room: 86 + access: [] + - target_room: 85 + access: ["Claw"] +- name: Mine Parallel Room + id: 88 + game_objects: + - name: "Mine - Parallel Room West Box" + object_id: 0x77 + type: "Box" + access: ["Claw"] + - name: "Mine - Parallel Room East Box" + object_id: 0x78 + type: "Box" + access: ["Claw"] + links: + - target_room: 84 + entrance: 185 + teleporter: [100, 3] + access: [] +- name: Mine Crescent Room + id: 89 + game_objects: + - name: "Mine - Crescent Room Chest" + object_id: 0x10 + type: "Chest" + access: [] + links: + - target_room: 86 + entrance: 186 + teleporter: [101, 3] + access: [] +- name: Mine Climbing Room + id: 90 + game_objects: + - name: "Mine - Glitchy Collision Cave Box" + object_id: 0x76 + type: "Box" + access: ["Claw"] + links: + - target_room: 85 + entrance: 187 + teleporter: [102, 3] + access: [] +- name: Mine Cliff + id: 91 + game_objects: + - name: "Mine - Cliff Southwest Box" + object_id: 0x79 + type: "Box" + access: [] + - name: "Mine - Cliff Northwest Box" + object_id: 0x7A + type: "Box" + access: [] + - name: "Mine - Cliff Northeast Box" + object_id: 0x7B + type: "Box" + access: [] + - name: "Mine - Cliff Southeast Box" + object_id: 0x7C + type: "Box" + access: [] + - name: "Mine - Reuben" + object_id: 7 + type: "NPC" + access: ["Reuben1"] + - name: "Reuben's dad Saved" + object_id: 0 + type: "Trigger" + on_trigger: ["ReubenDadSaved"] + access: ["MegaGrenade"] + links: + - target_room: 87 + entrance: 188 + teleporter: [100, 0] + access: [] +- name: Sealed Temple + id: 92 + game_objects: + - name: "Sealed Temple - West Box" + object_id: 0x7D + type: "Box" + access: [] + - name: "Sealed Temple - East Box" + object_id: 0x7E + type: "Box" + access: [] + links: + - target_room: 224 + entrance: 190 + teleporter: [16, 6] + access: [] + - target_room: 75 + entrance: 191 + teleporter: [63, 8] + access: ["GeminiCrest"] +- name: Volcano Base + id: 93 + game_objects: + - name: "Volcano - Base Chest" + object_id: 0x11 + type: "Chest" + access: [] + - name: "Volcano - Base West Box" + object_id: 0x7F + type: "Box" + access: [] + - name: "Volcano - Base East Left Box" + object_id: 0x80 + type: "Box" + access: [] + - name: "Volcano - Base East Right Box" + object_id: 0x81 + type: "Box" + access: [] + links: + - target_room: 224 + entrance: 192 + teleporter: [103, 0] + access: [] + - target_room: 98 + entrance: 196 + teleporter: [31, 8] + access: [] + - target_room: 96 + entrance: 197 + teleporter: [30, 8] + access: [] +- name: Volcano Top Left + id: 94 + game_objects: + - name: "Volcano - Medusa Chest" + object_id: 0x12 + type: "Chest" + access: ["Medusa"] + - name: "Medusa" + object_id: 0 + type: "Trigger" + on_trigger: ["Medusa"] + access: [] + - name: "Volcano - Behind Medusa Box" + object_id: 0x82 + type: "Box" + access: [] + links: + - target_room: 209 + entrance: 199 + teleporter: [26, 8] + access: [] +- name: Volcano Top Right + id: 95 + game_objects: + - name: "Volcano - Top of the Volcano Left Box" + object_id: 0x83 + type: "Box" + access: [] + - name: "Volcano - Top of the Volcano Right Box" + object_id: 0x84 + type: "Box" + access: [] + links: + - target_room: 99 + entrance: 200 + teleporter: [79, 8] + access: [] +- name: Volcano Right Path + id: 96 + game_objects: + - name: "Volcano - Right Path Box" + object_id: 0x87 + type: "Box" + access: [] + links: + - target_room: 93 + entrance: 201 + teleporter: [15, 8] + access: [] +- name: Volcano Left Path + id: 98 + game_objects: + - name: "Volcano - Left Path Box" + object_id: 0x86 + type: "Box" + access: [] + links: + - target_room: 93 + entrance: 204 + teleporter: [27, 8] + access: [] + - target_room: 99 + entrance: 202 + teleporter: [25, 2] + access: [] + - target_room: 209 + entrance: 203 + teleporter: [26, 2] + access: [] +- name: Volcano Cross Left-Right + id: 99 + game_objects: [] + links: + - target_room: 95 + entrance: 206 + teleporter: [29, 8] + access: [] + - target_room: 98 + entrance: 205 + teleporter: [103, 3] + access: [] +- name: Volcano Cross Right-Left + id: 209 + game_objects: + - name: "Volcano - Crossover Section Box" + object_id: 0x85 + type: "Box" + access: [] + links: + - target_room: 98 + entrance: 208 + teleporter: [104, 3] + access: [] + - target_room: 94 + entrance: 207 + teleporter: [28, 8] + access: [] +- name: Lava Dome Inner Ring Main Loop + id: 100 + game_objects: + - name: "Lava Dome - Exterior Caldera Near Switch Cliff Box" + object_id: 0x88 + type: "Box" + access: [] + - name: "Lava Dome - Exterior South Cliff Box" + object_id: 0x89 + type: "Box" + access: [] + links: + - target_room: 224 + entrance: 209 + teleporter: [104, 0] + access: [] + - target_room: 113 + entrance: 211 + teleporter: [105, 0] + access: [] + - target_room: 114 + entrance: 212 + teleporter: [106, 0] + access: [] + - target_room: 116 + entrance: 213 + teleporter: [108, 0] + access: [] + - target_room: 118 + entrance: 214 + teleporter: [111, 0] + access: [] +- name: Lava Dome Inner Ring Center Ledge + id: 101 + game_objects: + - name: "Lava Dome - Exterior Center Dropoff Ledge Box" + object_id: 0x8A + type: "Box" + access: [] + links: + - target_room: 115 + entrance: 215 + teleporter: [107, 0] + access: [] + - target_room: 100 + access: ["Claw"] +- name: Lava Dome Inner Ring Plate Ledge + id: 102 + game_objects: + - name: "Lava Dome Plate" + object_id: 0 + type: "Trigger" + on_trigger: ["LavaDomePlate"] + access: [] + links: + - target_room: 119 + entrance: 216 + teleporter: [109, 0] + access: [] +- name: Lava Dome Inner Ring Upper Ledge West + id: 103 + game_objects: [] + links: + - target_room: 111 + entrance: 219 + teleporter: [112, 0] + access: [] + - target_room: 108 + entrance: 220 + teleporter: [113, 0] + access: [] + - target_room: 104 + access: ["Claw"] + - target_room: 100 + access: ["Claw"] +- name: Lava Dome Inner Ring Upper Ledge East + id: 104 + game_objects: [] + links: + - target_room: 110 + entrance: 218 + teleporter: [110, 0] + access: [] + - target_room: 103 + access: ["Claw"] +- name: Lava Dome Inner Ring Big Door Ledge + id: 105 + game_objects: [] + links: + - target_room: 107 + entrance: 221 + teleporter: [114, 0] + access: [] + - target_room: 121 + entrance: 222 + teleporter: [29, 2] + access: ["LavaDomePlate"] +- name: Lava Dome Inner Ring Tiny Bottom Ledge + id: 106 + game_objects: + - name: "Lava Dome - Exterior Dead End Caldera Box" + object_id: 0x8B + type: "Box" + access: [] + links: + - target_room: 120 + entrance: 226 + teleporter: [115, 0] + access: [] +- name: Lava Dome Jump Maze II + id: 107 + game_objects: + - name: "Lava Dome - Gold Maze Northwest Box" + object_id: 0x8C + type: "Box" + access: [] + - name: "Lava Dome - Gold Maze Southwest Box" + object_id: 0xF6 + type: "Box" + access: [] + - name: "Lava Dome - Gold Maze Northeast Box" + object_id: 0xF7 + type: "Box" + access: [] + - name: "Lava Dome - Gold Maze North Box" + object_id: 0xF8 + type: "Box" + access: [] + - name: "Lava Dome - Gold Maze Center Box" + object_id: 0xF9 + type: "Box" + access: [] + - name: "Lava Dome - Gold Maze Southeast Box" + object_id: 0xFA + type: "Box" + access: [] + links: + - target_room: 105 + entrance: 227 + teleporter: [116, 0] + access: [] + - target_room: 108 + entrance: 228 + teleporter: [119, 0] + access: [] + - target_room: 120 + entrance: 229 + teleporter: [120, 0] + access: [] +- name: Lava Dome Up-Down Corridor + id: 108 + game_objects: [] + links: + - target_room: 107 + entrance: 231 + teleporter: [118, 0] + access: [] + - target_room: 103 + entrance: 230 + teleporter: [117, 0] + access: [] +- name: Lava Dome Jump Maze I + id: 109 + game_objects: + - name: "Lava Dome - Bare Maze Leapfrog Alcove North Box" + object_id: 0x8D + type: "Box" + access: [] + - name: "Lava Dome - Bare Maze Leapfrog Alcove South Box" + object_id: 0x8E + type: "Box" + access: [] + - name: "Lava Dome - Bare Maze Center Box" + object_id: 0x8F + type: "Box" + access: [] + - name: "Lava Dome - Bare Maze Southwest Box" + object_id: 0x90 + type: "Box" + access: [] + links: + - target_room: 118 + entrance: 232 + teleporter: [121, 0] + access: [] + - target_room: 111 + entrance: 233 + teleporter: [122, 0] + access: [] +- name: Lava Dome Pointless Room + id: 110 + game_objects: [] + links: + - target_room: 104 + entrance: 234 + teleporter: [123, 0] + access: [] +- name: Lava Dome Lower Moon Helm Room + id: 111 + game_objects: + - name: "Lava Dome - U-Bend Room North Box" + object_id: 0x92 + type: "Box" + access: [] + - name: "Lava Dome - U-Bend Room South Box" + object_id: 0x93 + type: "Box" + access: [] + links: + - target_room: 103 + entrance: 235 + teleporter: [124, 0] + access: [] + - target_room: 109 + entrance: 236 + teleporter: [125, 0] + access: [] +- name: Lava Dome Moon Helm Room + id: 112 + game_objects: + - name: "Lava Dome - Beyond River Room Chest" + object_id: 0x13 + type: "Chest" + access: [] + - name: "Lava Dome - Beyond River Room Box" + object_id: 0x91 + type: "Box" + access: [] + links: + - target_room: 117 + entrance: 237 + teleporter: [126, 0] + access: [] +- name: Lava Dome Three Jumps Room + id: 113 + game_objects: + - name: "Lava Dome - Three Jumps Room Box" + object_id: 0x96 + type: "Box" + access: [] + links: + - target_room: 100 + entrance: 238 + teleporter: [127, 0] + access: [] +- name: Lava Dome Life Chest Room Lower Ledge + id: 114 + game_objects: + - name: "Lava Dome - Gold Bar Room Boulder Chest" + object_id: 0x1C + type: "Chest" + access: ["MegaGrenade"] + links: + - target_room: 100 + entrance: 239 + teleporter: [128, 0] + access: [] + - target_room: 115 + access: ["Claw"] +- name: Lava Dome Life Chest Room Upper Ledge + id: 115 + game_objects: + - name: "Lava Dome - Gold Bar Room Leapfrog Alcove Box West" + object_id: 0x94 + type: "Box" + access: [] + - name: "Lava Dome - Gold Bar Room Leapfrog Alcove Box East" + object_id: 0x95 + type: "Box" + access: [] + links: + - target_room: 101 + entrance: 240 + teleporter: [129, 0] + access: [] + - target_room: 114 + access: ["Claw"] +- name: Lava Dome Big Jump Room Main Area + id: 116 + game_objects: + - name: "Lava Dome - Lava River Room North Box" + object_id: 0x98 + type: "Box" + access: [] + - name: "Lava Dome - Lava River Room East Box" + object_id: 0x99 + type: "Box" + access: [] + - name: "Lava Dome - Lava River Room South Box" + object_id: 0x9A + type: "Box" + access: [] + links: + - target_room: 100 + entrance: 241 + teleporter: [133, 0] + access: [] + - target_room: 119 + entrance: 243 + teleporter: [132, 0] + access: [] + - target_room: 117 + access: ["MegaGrenade"] +- name: Lava Dome Big Jump Room MegaGrenade Area + id: 117 + game_objects: [] + links: + - target_room: 112 + entrance: 242 + teleporter: [131, 0] + access: [] + - target_room: 116 + access: ["Bomb"] +- name: Lava Dome Split Corridor + id: 118 + game_objects: + - name: "Lava Dome - Split Corridor Box" + object_id: 0x97 + type: "Box" + access: [] + links: + - target_room: 109 + entrance: 244 + teleporter: [130, 0] + access: [] + - target_room: 100 + entrance: 245 + teleporter: [134, 0] + access: [] +- name: Lava Dome Plate Corridor + id: 119 + game_objects: [] + links: + - target_room: 102 + entrance: 246 + teleporter: [135, 0] + access: [] + - target_room: 116 + entrance: 247 + teleporter: [137, 0] + access: [] +- name: Lava Dome Four Boxes Stairs + id: 120 + game_objects: + - name: "Lava Dome - Caldera Stairway West Left Box" + object_id: 0x9B + type: "Box" + access: [] + - name: "Lava Dome - Caldera Stairway West Right Box" + object_id: 0x9C + type: "Box" + access: [] + - name: "Lava Dome - Caldera Stairway East Left Box" + object_id: 0x9D + type: "Box" + access: [] + - name: "Lava Dome - Caldera Stairway East Right Box" + object_id: 0x9E + type: "Box" + access: [] + links: + - target_room: 107 + entrance: 248 + teleporter: [136, 0] + access: [] + - target_room: 106 + entrance: 249 + teleporter: [16, 0] + access: [] +- name: Lava Dome Hydra Room + id: 121 + game_objects: + - name: "Lava Dome - Dualhead Hydra Chest" + object_id: 0x14 + type: "Chest" + access: ["DualheadHydra"] + - name: "Dualhead Hydra" + object_id: 0 + type: "Trigger" + on_trigger: ["DualheadHydra"] + access: [] + - name: "Lava Dome - Hydra Room Northwest Box" + object_id: 0x9F + type: "Box" + access: [] + - name: "Lava Dome - Hydra Room Southweast Box" + object_id: 0xA0 + type: "Box" + access: [] + links: + - target_room: 105 + entrance: 250 + teleporter: [105, 3] + access: [] + - target_room: 122 + entrance: 251 + teleporter: [138, 0] + access: ["DualheadHydra"] +- name: Lava Dome Escape Corridor + id: 122 + game_objects: [] + links: + - target_room: 121 + entrance: 253 + teleporter: [139, 0] + access: [] +- name: Rope Bridge + id: 123 + game_objects: + - name: "Rope Bridge - West Box" + object_id: 0xA3 + type: "Box" + access: [] + - name: "Rope Bridge - East Box" + object_id: 0xA4 + type: "Box" + access: [] + links: + - target_room: 226 + entrance: 255 + teleporter: [140, 0] + access: [] +- name: Alive Forest + id: 124 + game_objects: + - name: "Alive Forest - Tree Stump Chest" + object_id: 0x15 + type: "Chest" + access: ["Axe"] + - name: "Alive Forest - Near Entrance Box" + object_id: 0xA5 + type: "Box" + access: ["Axe"] + - name: "Alive Forest - After Bridge Box" + object_id: 0xA6 + type: "Box" + access: ["Axe"] + - name: "Alive Forest - Gemini Stump Box" + object_id: 0xA7 + type: "Box" + access: ["Axe"] + links: + - target_room: 226 + entrance: 272 + teleporter: [142, 0] + access: ["Axe"] + - target_room: 21 + entrance: 275 + teleporter: [64, 8] + access: ["LibraCrest", "Axe"] + - target_room: 22 + entrance: 276 + teleporter: [65, 8] + access: ["GeminiCrest", "Axe"] + - target_room: 23 + entrance: 277 + teleporter: [66, 8] + access: ["MobiusCrest", "Axe"] + - target_room: 125 + entrance: 274 + teleporter: [143, 0] + access: ["Axe"] +- name: Giant Tree 1F Main Area + id: 125 + game_objects: + - name: "Giant Tree 1F - Northwest Box" + object_id: 0xA8 + type: "Box" + access: [] + - name: "Giant Tree 1F - Southwest Box" + object_id: 0xA9 + type: "Box" + access: [] + - name: "Giant Tree 1F - Center Box" + object_id: 0xAA + type: "Box" + access: [] + - name: "Giant Tree 1F - East Box" + object_id: 0xAB + type: "Box" + access: [] + links: + - target_room: 124 + entrance: 278 + teleporter: [56, 1] # [49, 8] script restored if no map shuffling + access: [] + - target_room: 202 + access: ["DragonClaw"] +- name: Giant Tree 1F North Island + id: 202 + game_objects: [] + links: + - target_room: 127 + entrance: 280 + teleporter: [144, 0] + access: [] + - target_room: 125 + access: ["DragonClaw"] +- name: Giant Tree 1F Central Island + id: 126 + game_objects: [] + links: + - target_room: 202 + access: ["DragonClaw"] +- name: Giant Tree 2F Main Lobby + id: 127 + game_objects: + - name: "Giant Tree 2F - North Box" + object_id: 0xAC + type: "Box" + access: [] + links: + - target_room: 126 + access: ["DragonClaw"] + - target_room: 125 + entrance: 281 + teleporter: [145, 0] + access: [] + - target_room: 133 + entrance: 283 + teleporter: [149, 0] + access: [] + - target_room: 129 + access: ["DragonClaw"] +- name: Giant Tree 2F West Ledge + id: 128 + game_objects: + - name: "Giant Tree 2F - Dropdown Ledge Box" + object_id: 0xAE + type: "Box" + access: [] + links: + - target_room: 140 + entrance: 284 + teleporter: [147, 0] + access: ["Sword"] + - target_room: 130 + access: ["DragonClaw"] +- name: Giant Tree 2F Lower Area + id: 129 + game_objects: + - name: "Giant Tree 2F - South Box" + object_id: 0xAD + type: "Box" + access: [] + links: + - target_room: 130 + access: ["Claw"] + - target_room: 131 + access: ["Claw"] +- name: Giant Tree 2F Central Island + id: 130 + game_objects: [] + links: + - target_room: 129 + access: ["Claw"] + - target_room: 135 + entrance: 282 + teleporter: [146, 0] + access: ["Sword"] +- name: Giant Tree 2F East Ledge + id: 131 + game_objects: [] + links: + - target_room: 129 + access: ["Claw"] + - target_room: 130 + access: ["DragonClaw"] +- name: Giant Tree 2F Meteor Chest Room + id: 132 + game_objects: + - name: "Giant Tree 2F - Gidrah Chest" + object_id: 0x16 + type: "Chest" + access: [] + links: + - target_room: 133 + entrance: 285 + teleporter: [148, 0] + access: [] +- name: Giant Tree 2F Mushroom Room + id: 133 + game_objects: + - name: "Giant Tree 2F - Mushroom Tunnel West Box" + object_id: 0xAF + type: "Box" + access: ["Axe"] + - name: "Giant Tree 2F - Mushroom Tunnel East Box" + object_id: 0xB0 + type: "Box" + access: ["Axe"] + links: + - target_room: 127 + entrance: 286 + teleporter: [150, 0] + access: ["Axe"] + - target_room: 132 + entrance: 287 + teleporter: [151, 0] + access: ["Axe", "Gidrah"] +- name: Giant Tree 3F Central Island + id: 135 + game_objects: + - name: "Giant Tree 3F - Central Island Box" + object_id: 0xB3 + type: "Box" + access: [] + links: + - target_room: 130 + entrance: 288 + teleporter: [152, 0] + access: [] + - target_room: 136 + access: ["Claw"] + - target_room: 137 + access: ["DragonClaw"] +- name: Giant Tree 3F Central Area + id: 136 + game_objects: + - name: "Giant Tree 3F - Center North Box" + object_id: 0xB1 + type: "Box" + access: [] + - name: "Giant Tree 3F - Center West Box" + object_id: 0xB2 + type: "Box" + access: [] + links: + - target_room: 135 + access: ["Claw"] + - target_room: 127 + access: [] + - target_room: 131 + access: [] +- name: Giant Tree 3F Lower Ledge + id: 137 + game_objects: [] + links: + - target_room: 135 + access: ["DragonClaw"] + - target_room: 142 + entrance: 289 + teleporter: [153, 0] + access: ["Sword"] +- name: Giant Tree 3F West Area + id: 138 + game_objects: + - name: "Giant Tree 3F - West Side Box" + object_id: 0xB4 + type: "Box" + access: [] + links: + - target_room: 128 + access: [] + - target_room: 210 + entrance: 290 + teleporter: [154, 0] + access: [] +- name: Giant Tree 3F Middle Up Island + id: 139 + game_objects: [] + links: + - target_room: 136 + access: ["Claw"] +- name: Giant Tree 3F West Platform + id: 140 + game_objects: [] + links: + - target_room: 139 + access: ["Claw"] + - target_room: 141 + access: ["Claw"] + - target_room: 128 + entrance: 291 + teleporter: [155, 0] + access: [] +- name: Giant Tree 3F North Ledge + id: 141 + game_objects: [] + links: + - target_room: 143 + entrance: 292 + teleporter: [156, 0] + access: ["Sword"] + - target_room: 139 + access: ["Claw"] + - target_room: 136 + access: ["Claw"] +- name: Giant Tree Worm Room Upper Ledge + id: 142 + game_objects: + - name: "Giant Tree 3F - Worm Room North Box" + object_id: 0xB5 + type: "Box" + access: ["Axe"] + - name: "Giant Tree 3F - Worm Room South Box" + object_id: 0xB6 + type: "Box" + access: ["Axe"] + links: + - target_room: 137 + entrance: 293 + teleporter: [157, 0] + access: ["Axe"] + - target_room: 210 + access: ["Axe", "Claw"] +- name: Giant Tree Worm Room Lower Ledge + id: 210 + game_objects: [] + links: + - target_room: 138 + entrance: 294 + teleporter: [158, 0] + access: [] +- name: Giant Tree 4F Lower Floor + id: 143 + game_objects: [] + links: + - target_room: 141 + entrance: 295 + teleporter: [159, 0] + access: [] + - target_room: 148 + entrance: 296 + teleporter: [160, 0] + access: [] + - target_room: 148 + entrance: 297 + teleporter: [161, 0] + access: [] + - target_room: 147 + entrance: 298 + teleporter: [162, 0] + access: ["Sword"] +- name: Giant Tree 4F Middle Floor + id: 144 + game_objects: + - name: "Giant Tree 4F - Highest Platform North Box" + object_id: 0xB7 + type: "Box" + access: [] + - name: "Giant Tree 4F - Highest Platform South Box" + object_id: 0xB8 + type: "Box" + access: [] + links: + - target_room: 149 + entrance: 299 + teleporter: [163, 0] + access: [] + - target_room: 145 + access: ["Claw"] + - target_room: 146 + access: ["DragonClaw"] +- name: Giant Tree 4F Upper Floor + id: 145 + game_objects: [] + links: + - target_room: 150 + entrance: 300 + teleporter: [164, 0] + access: ["Sword"] + - target_room: 144 + access: ["Claw"] +- name: Giant Tree 4F South Ledge + id: 146 + game_objects: + - name: "Giant Tree 4F - Hook Ledge Northeast Box" + object_id: 0xB9 + type: "Box" + access: [] + - name: "Giant Tree 4F - Hook Ledge Southwest Box" + object_id: 0xBA + type: "Box" + access: [] + links: + - target_room: 144 + access: ["DragonClaw"] +- name: Giant Tree 4F Slime Room East Area + id: 147 + game_objects: + - name: "Giant Tree 4F - East Slime Room Box" + object_id: 0xBC + type: "Box" + access: ["Axe"] + links: + - target_room: 143 + entrance: 304 + teleporter: [168, 0] + access: [] +- name: Giant Tree 4F Slime Room West Area + id: 148 + game_objects: [] + links: + - target_room: 143 + entrance: 303 + teleporter: [167, 0] + access: ["Axe"] + - target_room: 143 + entrance: 302 + teleporter: [166, 0] + access: ["Axe"] + - target_room: 149 + access: ["Axe", "Claw"] +- name: Giant Tree 4F Slime Room Platform + id: 149 + game_objects: + - name: "Giant Tree 4F - West Slime Room Box" + object_id: 0xBB + type: "Box" + access: [] + links: + - target_room: 144 + entrance: 301 + teleporter: [165, 0] + access: [] + - target_room: 148 + access: ["Claw"] +- name: Giant Tree 5F Lower Area + id: 150 + game_objects: + - name: "Giant Tree 5F - Northwest Left Box" + object_id: 0xBD + type: "Box" + access: [] + - name: "Giant Tree 5F - Northwest Right Box" + object_id: 0xBE + type: "Box" + access: [] + - name: "Giant Tree 5F - South Left Box" + object_id: 0xBF + type: "Box" + access: [] + - name: "Giant Tree 5F - South Right Box" + object_id: 0xC0 + type: "Box" + access: [] + links: + - target_room: 145 + entrance: 305 + teleporter: [169, 0] + access: [] + - target_room: 151 + access: ["Claw"] + - target_room: 143 + access: [] +- name: Giant Tree 5F Gidrah Platform + id: 151 + game_objects: + - name: "Gidrah" + object_id: 0 + type: "Trigger" + on_trigger: ["Gidrah"] + access: [] + links: + - target_room: 150 + access: ["Claw"] +- name: Kaidge Temple Lower Ledge + id: 152 + game_objects: [] + links: + - target_room: 226 + entrance: 307 + teleporter: [18, 6] + access: [] + - target_room: 153 + access: ["Claw"] +- name: Kaidge Temple Upper Ledge + id: 153 + game_objects: + - name: "Kaidge Temple - Box" + object_id: 0xC1 + type: "Box" + access: [] + links: + - target_room: 185 + entrance: 308 + teleporter: [71, 8] + access: ["MobiusCrest"] + - target_room: 152 + access: ["Claw"] +- name: Windhole Temple + id: 154 + game_objects: + - name: "Windhole Temple - Box" + object_id: 0xC2 + type: "Box" + access: [] + links: + - target_room: 226 + entrance: 309 + teleporter: [173, 0] + access: [] +- name: Mount Gale + id: 155 + game_objects: + - name: "Mount Gale - Dullahan Chest" + object_id: 0x17 + type: "Chest" + access: ["DragonClaw", "Dullahan"] + - name: "Dullahan" + object_id: 0 + type: "Trigger" + on_trigger: ["Dullahan"] + access: ["DragonClaw"] + - name: "Mount Gale - East Box" + object_id: 0xC3 + type: "Box" + access: ["DragonClaw"] + - name: "Mount Gale - West Box" + object_id: 0xC4 + type: "Box" + access: [] + links: + - target_room: 226 + entrance: 310 + teleporter: [174, 0] + access: [] +- name: Windia + id: 156 + game_objects: [] + links: + - target_room: 226 + entrance: 312 + teleporter: [10, 6] + access: [] + - target_room: 157 + entrance: 320 + teleporter: [30, 5] + access: [] + - target_room: 163 + entrance: 321 + teleporter: [97, 8] + access: [] + - target_room: 165 + entrance: 322 + teleporter: [32, 5] + access: [] + - target_room: 159 + entrance: 323 + teleporter: [176, 4] + access: [] + - target_room: 160 + entrance: 324 + teleporter: [177, 4] + access: [] +- name: Otto's House + id: 157 + game_objects: + - name: "Otto" + object_id: 0 + type: "Trigger" + on_trigger: ["RainbowBridge"] + access: ["ThunderRock"] + links: + - target_room: 156 + entrance: 327 + teleporter: [106, 3] + access: [] + - target_room: 158 + entrance: 326 + teleporter: [33, 2] + access: [] +- name: Otto's Attic + id: 158 + game_objects: + - name: "Windia - Otto's Attic Box" + object_id: 0xC5 + type: "Box" + access: [] + links: + - target_room: 157 + entrance: 328 + teleporter: [107, 3] + access: [] +- name: Windia Kid House + id: 159 + game_objects: [] + links: + - target_room: 156 + entrance: 329 + teleporter: [178, 0] + access: [] + - target_room: 161 + entrance: 330 + teleporter: [180, 0] + access: [] +- name: Windia Old People House + id: 160 + game_objects: [] + links: + - target_room: 156 + entrance: 331 + teleporter: [179, 0] + access: [] + - target_room: 162 + entrance: 332 + teleporter: [181, 0] + access: [] +- name: Windia Kid House Basement + id: 161 + game_objects: [] + links: + - target_room: 159 + entrance: 333 + teleporter: [182, 0] + access: [] + - target_room: 79 + entrance: 334 + teleporter: [44, 8] + access: ["MobiusCrest"] +- name: Windia Old People House Basement + id: 162 + game_objects: + - name: "Windia - Mobius Basement West Box" + object_id: 0xC8 + type: "Box" + access: [] + - name: "Windia - Mobius Basement East Box" + object_id: 0xC9 + type: "Box" + access: [] + links: + - target_room: 160 + entrance: 335 + teleporter: [183, 0] + access: [] + - target_room: 186 + entrance: 336 + teleporter: [43, 8] + access: ["MobiusCrest"] +- name: Windia Inn Lobby + id: 163 + game_objects: [] + links: + - target_room: 156 + entrance: 338 + teleporter: [135, 3] + access: [] + - target_room: 164 + entrance: 337 + teleporter: [102, 8] + access: [] +- name: Windia Inn Beds + id: 164 + game_objects: + - name: "Windia - Inn Bedroom North Box" + object_id: 0xC6 + type: "Box" + access: [] + - name: "Windia - Inn Bedroom South Box" + object_id: 0xC7 + type: "Box" + access: [] + - name: "Windia - Kaeli" + object_id: 15 + type: "NPC" + access: ["Kaeli2"] + links: + - target_room: 163 + entrance: 339 + teleporter: [216, 0] + access: [] +- name: Windia Vendor House + id: 165 + game_objects: + - name: "Windia - Vendor" + object_id: 16 + type: "NPC" + access: [] + links: + - target_room: 156 + entrance: 340 + teleporter: [108, 3] + access: [] +- name: Pazuzu Tower 1F Main Lobby + id: 166 + game_objects: + - name: "Pazuzu 1F" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu1F"] + access: [] + links: + - target_room: 226 + entrance: 341 + teleporter: [184, 0] + access: [] + - target_room: 180 + entrance: 345 + teleporter: [185, 0] + access: [] +- name: Pazuzu Tower 1F Boxes Room + id: 167 + game_objects: + - name: "Pazuzu's Tower 1F - Descent Bomb Wall West Box" + object_id: 0xCA + type: "Box" + access: ["Bomb"] + - name: "Pazuzu's Tower 1F - Descent Bomb Wall Center Box" + object_id: 0xCB + type: "Box" + access: ["Bomb"] + - name: "Pazuzu's Tower 1F - Descent Bomb Wall East Box" + object_id: 0xCC + type: "Box" + access: ["Bomb"] + - name: "Pazuzu's Tower 1F - Descent Box" + object_id: 0xCD + type: "Box" + access: [] + links: + - target_room: 169 + entrance: 349 + teleporter: [187, 0] + access: [] +- name: Pazuzu Tower 1F Southern Platform + id: 168 + game_objects: [] + links: + - target_room: 169 + entrance: 346 + teleporter: [186, 0] + access: [] + - target_room: 166 + access: ["DragonClaw"] +- name: Pazuzu 2F + id: 169 + game_objects: + - name: "Pazuzu's Tower 2F - East Room West Box" + object_id: 0xCE + type: "Box" + access: [] + - name: "Pazuzu's Tower 2F - East Room East Box" + object_id: 0xCF + type: "Box" + access: [] + - name: "Pazuzu 2F Lock" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu2FLock"] + access: ["Axe"] + - name: "Pazuzu 2F" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu2F"] + access: ["Bomb"] + links: + - target_room: 183 + entrance: 350 + teleporter: [188, 0] + access: [] + - target_room: 168 + entrance: 351 + teleporter: [189, 0] + access: [] + - target_room: 167 + entrance: 352 + teleporter: [190, 0] + access: [] + - target_room: 171 + entrance: 353 + teleporter: [191, 0] + access: [] +- name: Pazuzu 3F Main Room + id: 170 + game_objects: + - name: "Pazuzu's Tower 3F - Guest Room West Box" + object_id: 0xD0 + type: "Box" + access: [] + - name: "Pazuzu's Tower 3F - Guest Room East Box" + object_id: 0xD1 + type: "Box" + access: [] + - name: "Pazuzu 3F" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu3F"] + access: [] + links: + - target_room: 180 + entrance: 356 + teleporter: [192, 0] + access: [] + - target_room: 181 + entrance: 357 + teleporter: [193, 0] + access: [] +- name: Pazuzu 3F Central Island + id: 171 + game_objects: [] + links: + - target_room: 169 + entrance: 360 + teleporter: [194, 0] + access: [] + - target_room: 170 + access: ["DragonClaw"] + - target_room: 172 + access: ["DragonClaw"] +- name: Pazuzu 3F Southern Island + id: 172 + game_objects: + - name: "Pazuzu's Tower 3F - South Ledge Box" + object_id: 0xD2 + type: "Box" + access: [] + links: + - target_room: 173 + entrance: 361 + teleporter: [195, 0] + access: [] + - target_room: 171 + access: ["DragonClaw"] +- name: Pazuzu 4F + id: 173 + game_objects: + - name: "Pazuzu's Tower 4F - Elevator West Box" + object_id: 0xD3 + type: "Box" + access: ["Bomb"] + - name: "Pazuzu's Tower 4F - Elevator East Box" + object_id: 0xD4 + type: "Box" + access: ["Bomb"] + - name: "Pazuzu's Tower 4F - East Storage Room Chest" + object_id: 0x18 + type: "Chest" + access: [] + - name: "Pazuzu 4F Lock" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu4FLock"] + access: ["Axe"] + - name: "Pazuzu 4F" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu4F"] + access: ["Bomb"] + links: + - target_room: 183 + entrance: 362 + teleporter: [196, 0] + access: [] + - target_room: 184 + entrance: 363 + teleporter: [197, 0] + access: [] + - target_room: 172 + entrance: 364 + teleporter: [198, 0] + access: [] + - target_room: 175 + entrance: 365 + teleporter: [199, 0] + access: [] +- name: Pazuzu 5F Pazuzu Loop + id: 174 + game_objects: + - name: "Pazuzu 5F" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu5F"] + access: [] + links: + - target_room: 181 + entrance: 368 + teleporter: [200, 0] + access: [] + - target_room: 182 + entrance: 369 + teleporter: [201, 0] + access: [] +- name: Pazuzu 5F Upper Loop + id: 175 + game_objects: + - name: "Pazuzu's Tower 5F - North Box" + object_id: 0xD5 + type: "Box" + access: [] + - name: "Pazuzu's Tower 5F - South Box" + object_id: 0xD6 + type: "Box" + access: [] + links: + - target_room: 173 + entrance: 370 + teleporter: [202, 0] + access: [] + - target_room: 176 + entrance: 371 + teleporter: [203, 0] + access: [] +- name: Pazuzu 6F + id: 176 + game_objects: + - name: "Pazuzu's Tower 6F - Box" + object_id: 0xD7 + type: "Box" + access: [] + - name: "Pazuzu's Tower 6F - Chest" + object_id: 0x19 + type: "Chest" + access: [] + - name: "Pazuzu 6F Lock" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu6FLock"] + access: ["Bomb", "Axe"] + - name: "Pazuzu 6F" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu6F"] + access: ["Bomb"] + links: + - target_room: 184 + entrance: 374 + teleporter: [204, 0] + access: [] + - target_room: 175 + entrance: 375 + teleporter: [205, 0] + access: [] + - target_room: 178 + entrance: 376 + teleporter: [206, 0] + access: [] + - target_room: 178 + entrance: 377 + teleporter: [207, 0] + access: [] +- name: Pazuzu 7F Southwest Area + id: 177 + game_objects: [] + links: + - target_room: 182 + entrance: 380 + teleporter: [26, 0] + access: [] + - target_room: 178 + access: ["DragonClaw"] +- name: Pazuzu 7F Rest of the Area + id: 178 + game_objects: [] + links: + - target_room: 177 + access: ["DragonClaw"] + - target_room: 176 + entrance: 381 + teleporter: [27, 0] + access: [] + - target_room: 176 + entrance: 382 + teleporter: [28, 0] + access: [] + - target_room: 179 + access: ["DragonClaw", "Pazuzu2FLock", "Pazuzu4FLock", "Pazuzu6FLock", "Pazuzu1F", "Pazuzu2F", "Pazuzu3F", "Pazuzu4F", "Pazuzu5F", "Pazuzu6F"] +- name: Pazuzu 7F Sky Room + id: 179 + game_objects: + - name: "Pazuzu's Tower 7F - Pazuzu Chest" + object_id: 0x1A + type: "Chest" + access: [] + - name: "Pazuzu" + object_id: 0 + type: "Trigger" + on_trigger: ["Pazuzu"] + access: ["Pazuzu2FLock", "Pazuzu4FLock", "Pazuzu6FLock", "Pazuzu1F", "Pazuzu2F", "Pazuzu3F", "Pazuzu4F", "Pazuzu5F", "Pazuzu6F"] + links: + - target_room: 178 + access: ["DragonClaw"] +- name: Pazuzu 1F to 3F + id: 180 + game_objects: [] + links: + - target_room: 166 + entrance: 385 + teleporter: [29, 0] + access: [] + - target_room: 170 + entrance: 386 + teleporter: [30, 0] + access: [] +- name: Pazuzu 3F to 5F + id: 181 + game_objects: [] + links: + - target_room: 170 + entrance: 387 + teleporter: [40, 0] + access: [] + - target_room: 174 + entrance: 388 + teleporter: [41, 0] + access: [] +- name: Pazuzu 5F to 7F + id: 182 + game_objects: [] + links: + - target_room: 174 + entrance: 389 + teleporter: [38, 0] + access: [] + - target_room: 177 + entrance: 390 + teleporter: [39, 0] + access: [] +- name: Pazuzu 2F to 4F + id: 183 + game_objects: [] + links: + - target_room: 169 + entrance: 391 + teleporter: [21, 0] + access: [] + - target_room: 173 + entrance: 392 + teleporter: [22, 0] + access: [] +- name: Pazuzu 4F to 6F + id: 184 + game_objects: [] + links: + - target_room: 173 + entrance: 393 + teleporter: [2, 0] + access: [] + - target_room: 176 + entrance: 394 + teleporter: [3, 0] + access: [] +- name: Light Temple + id: 185 + game_objects: + - name: "Light Temple - Box" + object_id: 0xD8 + type: "Box" + access: [] + links: + - target_room: 230 + entrance: 395 + teleporter: [19, 6] + access: [] + - target_room: 153 + entrance: 396 + teleporter: [70, 8] + access: ["MobiusCrest"] +- name: Ship Dock + id: 186 + game_objects: + - name: "Ship Dock Access" + object_id: 0 + type: "Trigger" + on_trigger: ["ShipDockAccess"] + access: [] + links: + - target_room: 228 + entrance: 399 + teleporter: [17, 6] + access: [] + - target_room: 162 + entrance: 397 + teleporter: [61, 8] + access: ["MobiusCrest"] +- name: Mac Ship Deck + id: 187 + game_objects: + - name: "Mac Ship Steering Wheel" + object_id: 00 + type: "Trigger" + on_trigger: ["ShipSteeringWheel"] + access: [] + - name: "Mac's Ship Deck - North Box" + object_id: 0xD9 + type: "Box" + access: [] + - name: "Mac's Ship Deck - Center Box" + object_id: 0xDA + type: "Box" + access: [] + - name: "Mac's Ship Deck - South Box" + object_id: 0xDB + type: "Box" + access: [] + links: + - target_room: 229 + entrance: 400 + teleporter: [37, 8] + access: [] + - target_room: 188 + entrance: 401 + teleporter: [50, 8] + access: [] + - target_room: 188 + entrance: 402 + teleporter: [51, 8] + access: [] + - target_room: 188 + entrance: 403 + teleporter: [52, 8] + access: [] + - target_room: 189 + entrance: 404 + teleporter: [53, 8] + access: [] +- name: Mac Ship B1 Outer Ring + id: 188 + game_objects: + - name: "Mac's Ship B1 - Northwest Hook Platform Box" + object_id: 0xE4 + type: "Box" + access: ["DragonClaw"] + - name: "Mac's Ship B1 - Center Hook Platform Box" + object_id: 0xE5 + type: "Box" + access: ["DragonClaw"] + links: + - target_room: 187 + entrance: 405 + teleporter: [208, 0] + access: [] + - target_room: 187 + entrance: 406 + teleporter: [175, 0] + access: [] + - target_room: 187 + entrance: 407 + teleporter: [172, 0] + access: [] + - target_room: 193 + entrance: 408 + teleporter: [88, 0] + access: [] + - target_room: 193 + access: [] +- name: Mac Ship B1 Square Room + id: 189 + game_objects: [] + links: + - target_room: 187 + entrance: 409 + teleporter: [141, 0] + access: [] + - target_room: 192 + entrance: 410 + teleporter: [87, 0] + access: [] +- name: Mac Ship B1 Central Corridor + id: 190 + game_objects: + - name: "Mac's Ship B1 - Central Corridor Box" + object_id: 0xE6 + type: "Box" + access: [] + links: + - target_room: 192 + entrance: 413 + teleporter: [86, 0] + access: [] + - target_room: 191 + entrance: 412 + teleporter: [102, 0] + access: [] + - target_room: 193 + access: [] +- name: Mac Ship B2 South Corridor + id: 191 + game_objects: [] + links: + - target_room: 190 + entrance: 415 + teleporter: [55, 8] + access: [] + - target_room: 194 + entrance: 414 + teleporter: [57, 1] + access: [] +- name: Mac Ship B2 North Corridor + id: 192 + game_objects: [] + links: + - target_room: 190 + entrance: 416 + teleporter: [56, 8] + access: [] + - target_room: 189 + entrance: 417 + teleporter: [57, 8] + access: [] +- name: Mac Ship B2 Outer Ring + id: 193 + game_objects: + - name: "Mac's Ship B2 - Barrel Room South Box" + object_id: 0xDF + type: "Box" + access: [] + - name: "Mac's Ship B2 - Barrel Room North Box" + object_id: 0xE0 + type: "Box" + access: [] + - name: "Mac's Ship B2 - Southwest Room Box" + object_id: 0xE1 + type: "Box" + access: [] + - name: "Mac's Ship B2 - Southeast Room Box" + object_id: 0xE2 + type: "Box" + access: [] + links: + - target_room: 188 + entrance: 418 + teleporter: [58, 8] + access: [] +- name: Mac Ship B1 Mac Room + id: 194 + game_objects: + - name: "Mac's Ship B1 - Mac Room Chest" + object_id: 0x1B + type: "Chest" + access: [] + - name: "Captain Mac" + object_id: 0 + type: "Trigger" + on_trigger: ["ShipLoaned"] + access: ["CaptainCap"] + links: + - target_room: 191 + entrance: 424 + teleporter: [101, 0] + access: [] +- name: Doom Castle Corridor of Destiny + id: 195 + game_objects: [] + links: + - target_room: 201 + entrance: 428 + teleporter: [84, 0] + access: [] + - target_room: 196 + entrance: 429 + teleporter: [35, 2] + access: [] + - target_room: 197 + entrance: 430 + teleporter: [209, 0] + access: ["StoneGolem"] + - target_room: 198 + entrance: 431 + teleporter: [211, 0] + access: ["StoneGolem", "TwinheadWyvern"] + - target_room: 199 + entrance: 432 + teleporter: [13, 2] + access: ["StoneGolem", "TwinheadWyvern", "Zuh"] +- name: Doom Castle Ice Floor + id: 196 + game_objects: + - name: "Doom Castle 4F - Northwest Room Box" + object_id: 0xE7 + type: "Box" + access: ["Sword", "DragonClaw"] + - name: "Doom Castle 4F - Southwest Room Box" + object_id: 0xE8 + type: "Box" + access: ["Sword", "DragonClaw"] + - name: "Doom Castle 4F - Northeast Room Box" + object_id: 0xE9 + type: "Box" + access: ["Sword"] + - name: "Doom Castle 4F - Southeast Room Box" + object_id: 0xEA + type: "Box" + access: ["Sword", "DragonClaw"] + - name: "Stone Golem" + object_id: 0 + type: "Trigger" + on_trigger: ["StoneGolem"] + access: ["Sword", "DragonClaw"] + links: + - target_room: 195 + entrance: 433 + teleporter: [109, 3] + access: [] +- name: Doom Castle Lava Floor + id: 197 + game_objects: + - name: "Doom Castle 5F - North Left Box" + object_id: 0xEB + type: "Box" + access: ["DragonClaw"] + - name: "Doom Castle 5F - North Right Box" + object_id: 0xEC + type: "Box" + access: ["DragonClaw"] + - name: "Doom Castle 5F - South Left Box" + object_id: 0xED + type: "Box" + access: ["DragonClaw"] + - name: "Doom Castle 5F - South Right Box" + object_id: 0xEE + type: "Box" + access: ["DragonClaw"] + - name: "Twinhead Wyvern" + object_id: 0 + type: "Trigger" + on_trigger: ["TwinheadWyvern"] + access: ["DragonClaw"] + links: + - target_room: 195 + entrance: 434 + teleporter: [210, 0] + access: [] +- name: Doom Castle Sky Floor + id: 198 + game_objects: + - name: "Doom Castle 6F - West Box" + object_id: 0xEF + type: "Box" + access: [] + - name: "Doom Castle 6F - East Box" + object_id: 0xF0 + type: "Box" + access: [] + - name: "Zuh" + object_id: 0 + type: "Trigger" + on_trigger: ["Zuh"] + access: ["DragonClaw"] + links: + - target_room: 195 + entrance: 435 + teleporter: [212, 0] + access: [] + - target_room: 197 + access: [] +- name: Doom Castle Hero Room + id: 199 + game_objects: + - name: "Doom Castle Hero Chest 01" + object_id: 0xF2 + type: "Chest" + access: [] + - name: "Doom Castle Hero Chest 02" + object_id: 0xF3 + type: "Chest" + access: [] + - name: "Doom Castle Hero Chest 03" + object_id: 0xF4 + type: "Chest" + access: [] + - name: "Doom Castle Hero Chest 04" + object_id: 0xF5 + type: "Chest" + access: [] + links: + - target_room: 200 + entrance: 436 + teleporter: [54, 0] + access: [] + - target_room: 195 + entrance: 441 + teleporter: [110, 3] + access: [] +- name: Doom Castle Dark King Room + id: 200 + game_objects: [] + links: + - target_room: 199 + entrance: 442 + teleporter: [52, 0] + access: [] diff --git a/worlds/ffmq/data/settings.yaml b/worlds/ffmq/data/settings.yaml new file mode 100644 index 0000000000..826a8c744d --- /dev/null +++ b/worlds/ffmq/data/settings.yaml @@ -0,0 +1,183 @@ +# YAML Preset file for FFMQR +Final Fantasy Mystic Quest: + enemies_density: + All: 0 + ThreeQuarter: 0 + Half: 0 + Quarter: 0 + None: 0 + chests_shuffle: + Prioritize: 0 + Include: 0 + shuffle_boxes_content: + true: 0 + false: 0 + npcs_shuffle: + Prioritize: 0 + Include: 0 + Exclude: 0 + battlefields_shuffle: + Prioritize: 0 + Include: 0 + Exclude: 0 + logic_options: + Friendly: 0 + Standard: 0 + Expert: 0 + shuffle_enemies_position: + true: 0 + false: 0 + enemies_scaling_lower: + Quarter: 0 + Half: 0 + ThreeQuarter: 0 + Normal: 0 + OneAndQuarter: 0 + OneAndHalf: 0 + Double: 0 + DoubleAndHalf: 0 + Triple: 0 + enemies_scaling_upper: + Quarter: 0 + Half: 0 + ThreeQuarter: 0 + Normal: 0 + OneAndQuarter: 0 + OneAndHalf: 0 + Double: 0 + DoubleAndHalf: 0 + Triple: 0 + bosses_scaling_lower: + Quarter: 0 + Half: 0 + ThreeQuarter: 0 + Normal: 0 + OneAndQuarter: 0 + OneAndHalf: 0 + Double: 0 + DoubleAndHalf: 0 + Triple: 0 + bosses_scaling_upper: + Quarter: 0 + Half: 0 + ThreeQuarter: 0 + Normal: 0 + OneAndQuarter: 0 + OneAndHalf: 0 + Double: 0 + DoubleAndHalf: 0 + Triple: 0 + enemizer_attacks: + Normal: 0 + Safe: 0 + Chaos: 0 + SelfDestruct: 0 + SimpleShuffle: 0 + enemizer_groups: + MobsOnly: 0 + MobsBosses: 0 + MobsBossesDK: 0 + shuffle_res_weak_type: + true: 0 + false: 0 + leveling_curve: + Half: 0 + Normal: 0 + OneAndHalf: 0 + Double: 0 + DoubleAndHalf: 0 + Triple: 0 + Quadruple: 0 + companion_leveling_type: + Quests: 0 + QuestsExtended: 0 + SaveCrystalsIndividual: 0 + SaveCrystalsAll: 0 + BenPlus0: 0 + BenPlus5: 0 + BenPlus10: 0 + companion_spellbook_type: + Standard: 0 + Extended: 0 + RandomBalanced: 0 + RandomChaos: 0 + starting_companion: + None: 0 + Kaeli: 0 + Tristam: 0 + Phoebe: 0 + Reuben: 0 + Random: 0 + RandomPlusNone: 0 + available_companions: + Zero: 0 + One: 0 + Two: 0 + Three: 0 + Four: 0 + Random14: 0 + Random04: 0 + companions_locations: + Standard: 0 + Shuffled: 0 + ShuffledExtended: 0 + kaelis_mom_fight_minotaur: + true: 0 + false: 0 + battles_quantity: + Ten: 0 + Seven: 0 + Five: 0 + Three: 0 + One: 0 + RandomHigh: 0 + RandomLow: 0 + shuffle_battlefield_rewards: + true: 0 + false: 0 + random_starting_weapon: + true: 0 + false: 0 + progressive_gear: + true: 0 + false: 0 + tweaked_dungeons: + true: 0 + false: 0 + doom_castle_mode: + Standard: 0 + BossRush: 0 + DarkKingOnly: 0 + doom_castle_shortcut: + true: 0 + false: 0 + sky_coin_mode: + Standard: 0 + StartWith: 0 + SaveTheCrystals: 0 + ShatteredSkyCoin: 0 + sky_coin_fragments_qty: + Low16: 0 + Mid24: 0 + High32: 0 + RandomNarrow: 0 + RandomWide: 0 + enable_spoilers: + true: 0 + false: 0 + progressive_formations: + Disabled: 0 + RegionsStrict: 0 + RegionsKeepType: 0 + map_shuffling: + None: 0 + Overworld: 0 + Dungeons: 0 + OverworldDungeons: 0 + Everything: 0 + crest_shuffle: + true: 0 + false: 0 +description: Generated by Archipelago +game: Final Fantasy Mystic Quest +name: Player diff --git a/worlds/ffmq/docs/en_Final Fantasy Mystic Quest.md b/worlds/ffmq/docs/en_Final Fantasy Mystic Quest.md new file mode 100644 index 0000000000..dd4ea354fa --- /dev/null +++ b/worlds/ffmq/docs/en_Final Fantasy Mystic Quest.md @@ -0,0 +1,33 @@ +# Final Fantasy Mystic Quest + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What does randomization do to this game? + +Besides items being shuffled, you have multiple options for shuffling maps, crest warps, and battlefield locations. +There are a number of other options for tweaking the difficulty of the game. + +## What items and locations get shuffled? + +Items received normally through chests, from NPCs, or battlefields are shuffled. Optionally, you may also include +the items from brown boxes. + +## Which items can be in another player's world? + +Any of the items which can be shuffled may also be placed into another player's world. + +## What does another world's item look like in Final Fantasy Mystic Quest? + +For locations that are originally boxes or chests, they will appear as a box if the item in it is categorized as a +filler item, and a chest if it contains a useful or advancement item. Trap items may randomly appear as a box or chest. +When opening a chest with an item for another player, you will see the Archipelago icon and it will tell you you've +found an "Archipelago Item" + +## When the player receives an item, what happens? + +A dialogue box will open to show you the item you've received. You will not receive items while you are in battle, +menus, or the overworld (except sometimes when closing the menu). + diff --git a/worlds/ffmq/docs/setup_en.md b/worlds/ffmq/docs/setup_en.md new file mode 100644 index 0000000000..9d9088dbc2 --- /dev/null +++ b/worlds/ffmq/docs/setup_en.md @@ -0,0 +1,162 @@ +# Final Fantasy Mystic Quest Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). Make sure to check the box for `SNI Client` + +- Hardware or software capable of loading and playing SNES ROM files + - An emulator capable of connecting to SNI such as: + - snes9x-rr from: [snes9x rr](https://github.com/gocha/snes9x-rr/releases), + - BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html) + - RetroArch 1.10.1 or newer from: [RetroArch Website](https://retroarch.com?page=platforms). Or, + - An SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other + compatible hardware + +- Your legally obtained Final Fantasy Mystic Quest 1.1 ROM file, probably named `Final Fantasy - Mystic Quest (U) (V1.1).sfc` +The Archipelago community cannot supply you with this. + +## Installation Procedures + +### Windows Setup + +1. During the installation of Archipelago, you will have been asked to install the SNI Client. If you did not do this, + or you are on an older version, you may run the installer again to install the SNI Client. +2. If you are using an emulator, you should assign your Lua capable emulator as your default program for launching ROM + files. + 1. Extract your emulator's folder to your Desktop, or somewhere you will remember. + 2. Right-click on a ROM file and select **Open with...** + 3. Check the box next to **Always use this app to open .sfc files** + 4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC** + 5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside the folder you + extracted in step one. + +## Create a Config (.yaml) File + +### What is a config file and why do I need one? + +See the guide on setting up a basic YAML at the Archipelago setup +guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en) + +### Where do I get a config file? + +The Player Settings page on the website allows you to configure your personal settings and export a config file from +them. Player settings page: [Final Fantasy Mystic Quest Player Settings Page](/games/Final%20Fantasy%20Mystic%20Quest/player-settings) + +### 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) + +## Generating a Single-Player Game + +1. Navigate to the Player Settings page, configure your options, and click the "Generate Game" button. + - Player Settings page: [Final Fantasy Mystic Quest Player Settings Page](/games/Final%20Fantasy%20Mystic%20Quest/player-settings) +2. You will be presented with a "Seed Info" page. +3. Click the "Create New Room" link. +4. You will be presented with a server page, from which you can download your `.apmq` patch file. +5. Go to the [FFMQR website](https://ffmqrando.net/Archipelago) and select your Final Fantasy Mystic Quest 1.1 ROM +and the .apmq file you received, choose optional preferences, and click `Generate` to get your patched ROM. +7. Since this is a single-player game, you will no longer need the client, so feel free to close it. + +## Joining a MultiWorld Game + +### Obtain your patch file and create your ROM + +When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done, +the host will provide you with either a link to download your patch file, or with a zip file containing +everyone's patch files. Your patch file should have a `.apmq` extension. + +Go to the [FFMQR website](https://ffmqrando.net/Archipelago) and select your Final Fantasy Mystic Quest 1.1 ROM +and the .apmq file you received, choose optional preferences, and click `Generate` to get your patched ROM. + +Manually launch the SNI Client, and run the patched ROM in your chosen software or hardware. + +### Connect to the client + +#### With an emulator + +When the client launched automatically, SNI should have also automatically launched in the background. If this is its +first time launching, you may be prompted to allow it to communicate through the Windows Firewall. + +##### snes9x-rr + +1. Load your ROM file if it hasn't already been loaded. +2. Click on the File menu and hover on **Lua Scripting** +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. +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. + +##### BizHawk + +1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following these + menu options: + `Config --> Cores --> SNES --> BSNES` + Once you have changed the loaded core, you must restart BizHawk. +2. Load your ROM file if it hasn't already been loaded. +3. Click on the Tools menu and click on **Lua Console** +4. Click the Open Folder icon that says `Open Script` via the tooltip on mouse hover, or click the Script Menu then `Open Script...`, or press `Ctrl-O`. +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. Please note the most recent versions of BizHawk are 64-bit only. + +##### RetroArch 1.10.1 or newer + +You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.1. + +1. Enter the RetroArch main menu screen. +2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. +3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default + Network Command Port at 55355. + +![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png) +4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury + Performance)". + +When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to +read ROM data. + +#### With hardware + +This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do +this now. SD2SNES and FXPak Pro users may download the appropriate firmware on the SD2SNES releases page. SD2SNES +releases page: [SD2SNES Releases Page](https://github.com/RedGuyyyy/sd2snes/releases) + +Other hardware may find helpful information on the usb2snes platforms +page: [usb2snes Supported Platforms Page](http://usb2snes.com/#supported-platforms) + +1. Close your emulator, which may have auto-launched. +2. Power on your device and load the ROM. + +### Connect to the Archipelago Server + +The patch file which launched your client should have automatically connected you to the AP Server. There are a few +reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the +client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it +into the "Server" input field then press enter. + +The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected". + +### Play the game + +When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations on +successfully joining a multiworld game! + +## Hosting a MultiWorld game + +The recommended way to host a game is to use our hosting service. The process is relatively simple: + +1. Collect config files from your players. +2. Create a zip file containing your players' config files. +3. Upload that zip file to the Generate page above. + - Generate page: [WebHost Seed Generation Page](/generate) +4. Wait a moment while the seed is generated. +5. When the seed is generated, you will be redirected to a "Seed Info" page. +6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players, so + they may download their patch files from there. +7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all + players in the game. Any observers may also be given the link to this page. +8. Once all players have joined, you may begin playing. diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index 520ad22525..ac5e1aa507 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -1,4 +1,5 @@ import collections +import logging import typing from BaseClasses import LocationProgressType, MultiWorld, Location, Region, Entrance @@ -81,15 +82,18 @@ def locality_rules(world: MultiWorld): i.name not in sending_blockers[i.player] and old_rule(i) -def exclusion_rules(world: MultiWorld, player: int, exclude_locations: typing.Set[str]) -> None: +def exclusion_rules(multiworld: MultiWorld, player: int, exclude_locations: typing.Set[str]) -> None: for loc_name in exclude_locations: try: - location = world.get_location(loc_name, player) + location = multiworld.get_location(loc_name, player) except KeyError as e: # failed to find the given location. Check if it's a legitimate location - if loc_name not in world.worlds[player].location_name_to_id: + if loc_name not in multiworld.worlds[player].location_name_to_id: raise Exception(f"Unable to exclude location {loc_name} in player {player}'s world.") from e else: - location.progress_type = LocationProgressType.EXCLUDED + if not location.event: + location.progress_type = LocationProgressType.EXCLUDED + else: + logging.warning(f"Unable to exclude location {loc_name} in player {player}'s world.") def set_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule): diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 456795dac4..6d5e20462f 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 e52ea20fd2..3e7c0bd4bd 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 +# Server Commands + +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. + +### General +- `!help` Returns a listing of available commands. +- `!license` Returns the software licensing information. +- `!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. + +### 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. + +### 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. +- `/options` Lists the server's current options, including password in plaintext. +- `/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 + +### Utilities +- `/countdown ` Starts a countdown sent to all players via text chat. Defaults to 10 seconds if no + argument is provided. +- `/option