diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..982e411032 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,210 @@ +.git +.github +.run +docs +test +typings +*Client.py + +.idea +.vscode + +*_Spoiler.txt +*.bmbp +*.apbp +*.apl2ac +*.apm3 +*.apmc +*.apz5 +*.aptloz +*.apemerald +*.pyc +*.pyd +*.sfc +*.z64 +*.n64 +*.nes +*.smc +*.sms +*.gb +*.gbc +*.gba +*.wixobj +*.lck +*.db3 +*multidata +*multisave +*.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 +/logs/ +_persistent_storage.yaml +mystery_result_*.yaml +*-errors.txt +success.txt +output/ +Output Logs/ +/factorio/ +/Minecraft Forge Server/ +/WebHostLib/static/generated +/freeze_requirements.txt +/Archipelago.zip +/setup.ini +/installdelete.iss +/data/user.kv +/datapackage +/custom_worlds + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so +*.dll + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +installer.log + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# vim editor +*.swp + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv* +env/ +venv/ +/venv*/ +ENV/ +env.bak/ +venv.bak/ +*.code-workspace +shell.nix + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Cython intermediates +_speedups.c +_speedups.cpp +_speedups.html + +# minecraft server stuff +jdk*/ +minecraft*/ +minecraft_versions.json +!worlds/minecraft/ + +# pyenv +.python-version + +#undertale stuff +/Undertale/ + +# OS General Files +.DS_Store +.AppleDouble +.LSOverride +Thumbs.db +[Dd]esktop.ini diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6b80965f0..721d63b1dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,12 @@ on: env: ENEMIZER_VERSION: 7.1 - APPIMAGETOOL_VERSION: 13 + # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, + # we check the sha256 and require manual intervention if it was updated. + APPIMAGETOOL_VERSION: continuous + APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' + APPIMAGE_RUNTIME_VERSION: continuous + APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e' permissions: # permissions required for attestation id-token: 'write' @@ -98,7 +103,7 @@ jobs: shell: bash run: | cd build/exe* - cp Players/Templates/Clique.yaml Players/ + cp Players/Templates/VVVVVV.yaml Players/ timeout 30 ./ArchipelagoGenerate - name: Store 7z uses: actions/upload-artifact@v4 @@ -134,10 +139,13 @@ jobs: - name: Install build-time dependencies run: | echo "PYTHON=python3.12" >> $GITHUB_ENV - wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage + wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage + echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c + wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64 + echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract - echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool + echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool chmod a+rx appimagetool - name: Download run-time dependencies run: | @@ -189,7 +197,7 @@ jobs: shell: bash run: | cd build/exe* - cp Players/Templates/Clique.yaml Players/ + cp Players/Templates/VVVVVV.yaml Players/ timeout 30 ./ArchipelagoGenerate - name: Store AppImage uses: actions/upload-artifact@v4 diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml index bc0f6999b6..4a7d403459 100644 --- a/.github/workflows/label-pull-requests.yml +++ b/.github/workflows/label-pull-requests.yml @@ -6,6 +6,8 @@ on: permissions: contents: read pull-requests: write +env: + GH_REPO: ${{ github.repository }} jobs: labeler: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a500f9a23b..1462560052 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,12 @@ on: env: ENEMIZER_VERSION: 7.1 - APPIMAGETOOL_VERSION: 13 + # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, + # we check the sha256 and require manual intervention if it was updated. + APPIMAGETOOL_VERSION: continuous + APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' + APPIMAGE_RUNTIME_VERSION: continuous + APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e' permissions: # permissions required for attestation id-token: 'write' @@ -122,10 +127,13 @@ jobs: - name: Install build-time dependencies run: | echo "PYTHON=python3.12" >> $GITHUB_ENV - wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage + wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage + echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c + wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64 + echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract - echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool + echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool chmod a+rx appimagetool - name: Download run-time dependencies run: | diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 88b5d12987..2d83c649e8 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -8,18 +8,24 @@ on: paths: - '**' - '!docs/**' + - '!deploy/**' - '!setup.py' + - '!Dockerfile' - '!*.iss' - '!.gitignore' + - '!.dockerignore' - '!.github/workflows/**' - '.github/workflows/unittests.yml' pull_request: paths: - '**' - '!docs/**' + - '!deploy/**' - '!setup.py' + - '!Dockerfile' - '!*.iss' - '!.gitignore' + - '!.dockerignore' - '!.github/workflows/**' - '.github/workflows/unittests.yml' diff --git a/.gitignore b/.gitignore index f50fc17e23..3bb4e68c99 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,6 @@ success.txt output/ Output Logs/ /factorio/ -/Minecraft Forge Server/ /WebHostLib/static/generated /freeze_requirements.txt /Archipelago.zip @@ -184,12 +183,6 @@ _speedups.c _speedups.cpp _speedups.html -# minecraft server stuff -jdk*/ -minecraft*/ -minecraft_versions.json -!worlds/minecraft/ - # pyenv .python-version diff --git a/AdventureClient.py b/AdventureClient.py index 91567fc0a0..b89b8f0600 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -11,6 +11,7 @@ from typing import List import Utils +from settings import get_settings from NetUtils import ClientStatus from Utils import async_start from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ @@ -80,8 +81,8 @@ class AdventureContext(CommonContext): self.local_item_locations = {} self.dragon_speed_info = {} - options = Utils.get_settings() - self.display_msgs = options["adventure_options"]["display_msgs"] + options = get_settings().adventure_options + self.display_msgs = options.display_msgs async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -102,7 +103,7 @@ class AdventureContext(CommonContext): def on_package(self, cmd: str, args: dict): if cmd == 'Connected': self.locations_array = None - if Utils.get_settings()["adventure_options"].get("death_link", False): + if get_settings().adventure_options.as_dict().get("death_link", False): self.set_deathlink = True async_start(self.get_freeincarnates_used()) elif cmd == "RoomInfo": @@ -406,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.atari_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue except CancelledError: pass @@ -415,8 +417,9 @@ async def atari_sync_task(ctx: AdventureContext): async def run_game(romfile): - auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True) - rom_args = Utils.get_settings()["adventure_options"].get("rom_args") + options = get_settings().adventure_options + auto_start = options.rom_start + rom_args = options.rom_args if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/BaseClasses.py b/BaseClasses.py index 1a06ef6b73..a9477a0312 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -5,12 +5,13 @@ import functools import logging import random import secrets +import warnings from argparse import Namespace -from collections import Counter, deque +from collections import Counter, deque, defaultdict from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, - Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) + Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) import dataclasses from typing_extensions import NotRequired, TypedDict @@ -153,17 +154,11 @@ class MultiWorld(): self.algorithm = 'balanced' self.groups = {} 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.required_locations = [] - self.light_world_light_cone = False - self.dark_world_light_cone = False - self.rupoor_cost = 10 - self.aga_randomness = True - self.save_and_quit_from_boss = True self.custom = False self.customitemarray = [] self.shuffle_ganon = True @@ -182,7 +177,7 @@ class MultiWorld(): set_player_attr('completion_condition', lambda state: True) self.worlds = {} self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " - "world's random object instead (usually self.random)") + "world's random object instead (usually self.random)", True) self.plando_options = PlandoOptions.none def get_all_ids(self) -> Tuple[int, ...]: @@ -227,17 +222,8 @@ class MultiWorld(): self.seed_name = name if name else str(self.seed) def set_options(self, args: Namespace) -> None: - # TODO - remove this section once all worlds use options dataclasses from worlds import AutoWorld - 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.", True) - option.update(getattr(args, option_key, {})) - setattr(self, option_key, option) - for player in self.player_ids: world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) @@ -438,12 +424,27 @@ class MultiWorld(): 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, allow_partial_entrances: bool = False, + def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False, collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState: - cached = getattr(self, "_all_state", None) - if use_cache and cached: - return cached.copy() + """ + Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those + specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items + it is able to reach, building as complete of a completed game state as possible. + :param use_cache: Deprecated and unused. + :param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while + sweeping, such as before entrance randomization is complete. + :param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this + state. + :param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed + items it can. + + :return: The completed CollectionState. + """ + if __debug__ and use_cache is not None: + # TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen + warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.", + DeprecationWarning) ret = CollectionState(self, allow_partial_entrances) for item in self.itempool: @@ -456,8 +457,6 @@ class MultiWorld(): if perform_sweep: ret.sweep_for_advancements() - if use_cache: - self._all_state = ret return ret def get_items(self) -> List[Item]: @@ -559,7 +558,9 @@ 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) -> bool: + def can_beat_game(self, + starting_state: Optional[CollectionState] = None, + locations: Optional[Iterable[Location]] = None) -> bool: if starting_state: if self.has_beaten_game(starting_state): return True @@ -568,25 +569,10 @@ class MultiWorld(): state = CollectionState(self) if self.has_beaten_game(state): return True - prog_locations = {location for location in self.get_locations() if location.item - and location.item.advancement and location not in state.locations_checked} - - while prog_locations: - 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: - if location.can_reach(state): - sphere.add(location) - - if not sphere: - # ran out of places and did not finish yet, quit - return False - - for location in sphere: - state.collect(location.item, True, location) - prog_locations -= sphere + for _ in state.sweep_for_advancements(locations, + yield_each_sweep=True, + checked_locations=state.locations_checked): if self.has_beaten_game(state): return True @@ -702,6 +688,12 @@ class MultiWorld(): sphere.append(locations.pop(n)) if not sphere: + if __debug__: + from Fill import FillError + raise FillError( + f"Could not access required locations for accessibility check. Missing: {locations}", + multiworld=self, + ) # ran out of places and did not finish yet, quit logging.warning(f"Could not access required locations for accessibility check." f" Missing: {locations}") @@ -865,20 +857,133 @@ class CollectionState(): "Please switch over to sweep_for_advancements.") return self.sweep_for_advancements(locations) - def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None: - if locations is None: - locations = self.multiworld.get_filled_locations() - reachable_advancements = True - # since the loop has a good chance to run more than once, only filter the advancements once - locations = {location for location in locations if location.advancement and location not in self.advancements} + def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]], + yield_each_sweep: bool) -> Iterator[None]: + """ + The implementation for sweep_for_advancements is separated here because it returns a generator due to the use + of a yield statement. + """ + all_players = {player for player, _ in advancements_per_player} + players_to_check = all_players + # As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds + # are allowed to logically depend on other worlds, so once there are no more players that should be checked + # under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the + # sweep is finished. + checking_if_finished = False + while players_to_check: + next_advancements_per_player: List[Tuple[int, List[Location]]] = [] + next_players_to_check = set() - while reachable_advancements: - reachable_advancements = {location for location in locations if location.can_reach(self)} - locations -= reachable_advancements - for advancement in reachable_advancements: - self.advancements.add(advancement) - assert isinstance(advancement.item, Item), "tried to collect Event with no Item" - self.collect(advancement.item, True, advancement) + for player, locations in advancements_per_player: + if player not in players_to_check: + next_advancements_per_player.append((player, locations)) + continue + + # Accessibility of each location is checked first because a player's region accessibility cache becomes + # stale whenever one of their own items is collected into the state. + reachable_locations: List[Location] = [] + unreachable_locations: List[Location] = [] + for location in locations: + if location.can_reach(self): + # Locations containing items that do not belong to `player` could be collected immediately + # because they won't stale `player`'s region accessibility cache, but, for simplicity, all the + # items at reachable locations are collected in a single loop. + reachable_locations.append(location) + else: + unreachable_locations.append(location) + if unreachable_locations: + next_advancements_per_player.append((player, unreachable_locations)) + + # A previous player's locations processed in the current `while players_to_check` iteration could have + # collected items belonging to `player`, but now that all of `player`'s reachable locations have been + # found, it can be assumed that `player` will not gain any more reachable locations until another one of + # their items is collected. + # It would be clearer to not add players to `next_players_to_check` in the first place if they have yet + # to be processed in the current `while players_to_check` iteration, but checking if a player should be + # added to `next_players_to_check` would need to be run once for every item that is collected, so it is + # more performant to instead discard `player` from `next_players_to_check` once their locations have + # been processed. + next_players_to_check.discard(player) + + # Collect the items from the reachable locations. + for advancement in reachable_locations: + self.advancements.add(advancement) + item = advancement.item + assert isinstance(item, Item), "tried to collect advancement Location with no Item" + if self.collect(item, True, advancement): + # The player the item belongs to may be able to reach additional locations in the next sweep + # iteration. + next_players_to_check.add(item.player) + + if not next_players_to_check: + if not checking_if_finished: + # It is assumed that each player's world only logically depends on itself, which may not be the + # case, so confirm that the sweep is finished by doing an extra iteration that checks every player. + checking_if_finished = True + next_players_to_check = all_players + else: + checking_if_finished = False + + players_to_check = next_players_to_check + advancements_per_player = next_advancements_per_player + + if yield_each_sweep: + yield + + @overload + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *, + yield_each_sweep: Literal[True], + checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ... + + @overload + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, + yield_each_sweep: Literal[False] = False, + checked_locations: Optional[Set[Location]] = None) -> None: ... + + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False, + checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]: + """ + Sweep through the locations that contain uncollected advancement items, collecting the items into the state + until there are no more reachable locations that contain uncollected advancement items. + + :param locations: The locations to sweep through, defaulting to all locations in the multiworld. + :param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration. + :param checked_locations: Optional override of locations to filter out from the locations argument, defaults to + self.advancements when None. + """ + if checked_locations is None: + checked_locations = self.advancements + + # Since the sweep loop usually performs many iterations, the locations are filtered in advance. + # A list of tuples is used, instead of a dictionary, because it is faster to iterate. + advancements_per_player: List[Tuple[int, List[Location]]] + if locations is None: + # `location.advancement` can only be True for filled locations, so unfilled locations are filtered out. + advancements_per_player = [] + for player, locations_dict in self.multiworld.regions.location_cache.items(): + filtered_locations = [location for location in locations_dict.values() + if location.advancement and location not in checked_locations] + if filtered_locations: + advancements_per_player.append((player, filtered_locations)) + else: + # Filter and separate the locations into a list for each player. + advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list) + for location in locations: + if location.advancement and location not in checked_locations: + advancements_per_player_dict[location.player].append(location) + # Convert to a list of tuples. + advancements_per_player = list(advancements_per_player_dict.items()) + del advancements_per_player_dict + + if yield_each_sweep: + # Return a generator that will yield at the end of each sweep iteration. + return self._sweep_for_advancements_impl(advancements_per_player, True) + else: + # Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations + # once started, then start and exhaust the generator by attempting to iterate it. + for _ in self._sweep_for_advancements_impl(advancements_per_player, False): + assert False, "Generator yielded when it should have run to completion without yielding" + return None # item name related def has(self, item: str, player: int, count: int = 1) -> bool: @@ -1146,13 +1251,13 @@ class Region: self.region_manager = region_manager def __getitem__(self, index: int) -> Location: - return self._list.__getitem__(index) + return self._list[index] def __setitem__(self, index: int, value: Location) -> None: raise NotImplementedError() def __len__(self) -> int: - return self._list.__len__() + return len(self._list) def __iter__(self): return iter(self._list) @@ -1166,8 +1271,8 @@ class Region: class LocationRegister(Register): def __delitem__(self, index: int) -> None: - location: Location = self._list.__getitem__(index) - self._list.__delitem__(index) + location: Location = self._list[index] + del self._list[index] del(self.region_manager.location_cache[location.player][location.name]) def insert(self, index: int, value: Location) -> None: @@ -1178,8 +1283,8 @@ class Region: class EntranceRegister(Register): def __delitem__(self, index: int) -> None: - entrance: Entrance = self._list.__getitem__(index) - self._list.__delitem__(index) + entrance: Entrance = self._list[index] + del self._list[index] del(self.region_manager.entrance_cache[entrance.player][entrance.name]) def insert(self, index: int, value: Entrance) -> None: @@ -1333,8 +1438,8 @@ class Region: Connects current region to regions in exit dictionary. Passed region names must exist first. :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided, - created entrances will be named "self.name -> connecting_region" - :param rules: rules for the exits from this region. format is {"connecting_region", rule} + created entrances will be named "self.name -> connecting_region" + :param rules: rules for the exits from this region. format is {"connecting_region": rule} """ if not isinstance(exits, Dict): exits = dict.fromkeys(exits) @@ -1426,27 +1531,43 @@ class Location: class ItemClassification(IntFlag): - filler = 0b0000 + filler = 0b00000 """ aka trash, as in filler items like ammo, currency etc """ - progression = 0b0001 + progression = 0b00001 """ Item that is logically relevant. Protects this item from being placed on excluded or unreachable locations. """ - useful = 0b0010 + useful = 0b00010 """ Item that is especially useful. Protects this item from being placed on excluded or unreachable locations. When combined with another flag like "progression", it means "an especially useful progression item". """ - trap = 0b0100 + trap = 0b00100 """ Item that is detrimental in some way. """ - skip_balancing = 0b1000 + skip_balancing = 0b01000 """ should technically never occur on its own Item that is logically relevant, but progression balancing should not touch. - Typically currency or other counted items. """ + + Possible reasons for why an item should not be pulled ahead by progression balancing: + 1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.) + 2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """ - progression_skip_balancing = 0b1001 # only progression gets balanced + deprioritized = 0b10000 + """ Should technically never occur on its own. + Will not be considered for priority locations, + unless Priority Locations Fill runs out of regular progression items before filling all priority locations. + + Should be used for items that would feel bad for the player to find on a priority location. + Usually, these are items that are plentiful or insignificant. """ + + progression_deprioritized_skip_balancing = 0b11001 + """ Since a common case of both skip_balancing and deprioritized is "insignificant progression", + these items often want both flags. """ + + progression_skip_balancing = 0b01001 # only progression gets balanced + progression_deprioritized = 0b10001 # only progression can be placed during priority fill def as_flag(self) -> int: """As Network API flag int.""" @@ -1494,6 +1615,10 @@ class Item: def trap(self) -> bool: return ItemClassification.trap in self.classification + @property + def deprioritized(self) -> bool: + return ItemClassification.deprioritized in self.classification + @property def filler(self) -> bool: return not (self.advancement or self.useful or self.trap) @@ -1603,21 +1728,19 @@ class Spoiler: # in the second phase, we cull each sphere such that the game is still beatable, # reducing each range of influence to the bare minimum required inside it - restore_later: Dict[Location, Item] = {} + required_locations = {location for sphere in collection_spheres for location in sphere} for num, sphere in reversed(tuple(enumerate(collection_spheres))): to_delete: Set[Location] = set() for location in sphere: - # we remove the item at location and check if game is still beatable + # we remove the location from required_locations to sweep from, and check if the game is still beatable logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, location.item.player) - old_item = location.item - location.item = None - if multiworld.can_beat_game(state_cache[num]): + required_locations.remove(location) + if multiworld.can_beat_game(state_cache[num], required_locations): to_delete.add(location) - restore_later[location] = old_item else: # still required, got to keep it around - location.item = old_item + required_locations.add(location) # cull entries in spheres for spoiler walkthrough at end sphere -= to_delete @@ -1634,7 +1757,7 @@ class Spoiler: logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) precollected_items.remove(item) multiworld.state.remove(item) - if not multiworld.can_beat_game(): + if not multiworld.can_beat_game(multiworld.state, required_locations): # Add the item back into `precollected_items` and collect it into `multiworld.state`. multiworld.push_precollected(item) else: @@ -1676,9 +1799,6 @@ class Spoiler: self.create_paths(state, collection_spheres) # repair the multiworld again - for location, item in restore_later.items(): - location.item = item - for item in removed_precollected: multiworld.push_precollected(item) diff --git a/CommonClient.py b/CommonClient.py index 3a5f51aeee..bd7113cb6f 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -21,7 +21,7 @@ import Utils if __name__ == "__main__": Utils.init_logging("TextClient", exception_logger="Client") -from MultiServer import CommandProcessor +from MultiServer import CommandProcessor, mark_raw from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) from Utils import Version, stream_input, async_start @@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor): self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"}) return True + def get_current_datapackage(self) -> dict[str, typing.Any]: + """ + Return datapackage for current game if known. + + :return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned. + """ + if not self.ctx.game: + return {} + checksum = self.ctx.checksums[self.ctx.game] + return Utils.load_data_package_for_checksum(self.ctx.game, checksum) + def _cmd_missing(self, filter_text = "") -> bool: """List all missing location checks, from your local game state. Can be given text, which will be used as filter.""" @@ -107,7 +118,9 @@ class ClientCommandProcessor(CommandProcessor): return False count = 0 checked_count = 0 - for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items(): + + lookup = self.get_current_datapackage().get("location_name_to_id", {}) + for location, location_id in lookup.items(): if filter_text and filter_text not in location: continue if location_id < 0: @@ -128,43 +141,91 @@ class ClientCommandProcessor(CommandProcessor): self.output("No missing location checks found.") return True - def _cmd_items(self): + def output_datapackage_part(self, key: str, name: str) -> bool: + """ + Helper to digest a specific section of this game's datapackage. + + :param key: The dictionary key in the datapackage. + :param name: Printed to the user as context for the part. + + :return: Whether the process was successful. + """ + if not self.ctx.game: + self.output(f"No game set, cannot determine {name}.") + return False + + lookup = self.get_current_datapackage().get(key) + if lookup is None: + self.output("datapackage not yet loaded, try again") + return False + + self.output(f"{name} for {self.ctx.game}") + for key in lookup: + self.output(key) + return True + + def _cmd_items(self) -> bool: """List all item names for the currently running game.""" - if not self.ctx.game: - self.output("No game set, cannot determine existing items.") - return False - self.output(f"Item Names for {self.ctx.game}") - for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: - self.output(item_name) + return self.output_datapackage_part("item_name_to_id", "Item Names") - def _cmd_item_groups(self): - """List all item group names for the currently running game.""" - if not self.ctx.game: - self.output("No game set, cannot determine existing item groups.") - return False - self.output(f"Item Group Names for {self.ctx.game}") - for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups: - self.output(group_name) - - def _cmd_locations(self): + def _cmd_locations(self) -> bool: """List all location names for the currently running game.""" - if not self.ctx.game: - self.output("No game set, cannot determine existing locations.") - return False - self.output(f"Location Names for {self.ctx.game}") - for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: - self.output(location_name) + return self.output_datapackage_part("location_name_to_id", "Location Names") - def _cmd_location_groups(self): - """List all location group names for the currently running game.""" - if not self.ctx.game: - self.output("No game set, cannot determine existing location groups.") - return False - self.output(f"Location Group Names for {self.ctx.game}") - for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups: - self.output(group_name) + def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"], + filter_key: str, + name: str) -> bool: + """ + Logs an item or location group from the player's game's datapackage. - def _cmd_ready(self): + :param group_key: Either Item or Location group to be processed. + :param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups. + :param name: Printed to the user as context for the part. + + :return: Whether the process was successful. + """ + if not self.ctx.game: + self.output(f"No game set, cannot determine existing {name} Groups.") + return False + lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\ + .get(self.ctx.game, {}).get(group_key, {}) + if lookup is None: + self.output("datapackage not yet loaded, try again") + return False + + if filter_key: + if filter_key not in lookup: + self.output(f"Unknown {name} Group {filter_key}") + return False + + self.output(f"{name}s for {name} Group \"{filter_key}\"") + for entry in lookup[filter_key]: + self.output(entry) + else: + self.output(f"{name} Groups for {self.ctx.game}") + for group in lookup: + self.output(group) + return True + + @mark_raw + def _cmd_item_groups(self, key: str = "") -> bool: + """ + List all item group names for the currently running game. + + :param key: Which item group to filter to. Will log all groups if empty. + """ + return self.output_group_part("item_name_groups", key, "Item") + + @mark_raw + def _cmd_location_groups(self, key: str = "") -> bool: + """ + List all location group names for the currently running game. + + :param key: Which item group to filter to. Will log all groups if empty. + """ + return self.output_group_part("location_name_groups", key, "Location") + + def _cmd_ready(self) -> bool: """Send ready status to server.""" self.ctx.ready = not self.ctx.ready if self.ctx.ready: @@ -174,6 +235,7 @@ class ClientCommandProcessor(CommandProcessor): state = ClientStatus.CLIENT_CONNECTED self.output("Unreadied.") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") + return True def default(self, raw: str): """The default message parser to be used when parsing any messages that do not match a command""" @@ -201,6 +263,7 @@ class CommonContext: # noinspection PyTypeChecker def __getitem__(self, key: str) -> typing.Mapping[int, str]: + assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead" return self._game_store[key] def __len__(self) -> int: @@ -210,7 +273,7 @@ class CommonContext: return iter(self._game_store) def __repr__(self) -> str: - return self._game_store.__repr__() + return repr(self._game_store) def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str: """Returns the name for an item/location id in the context of a specific game or own game if `game` is @@ -378,6 +441,8 @@ class CommonContext: self.jsontotextparser = JSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self) + if self.game: + self.checksums[self.game] = network_data_package["games"][self.game]["checksum"] self.update_data_package(network_data_package) # execution @@ -637,6 +702,24 @@ class CommonContext: for game, game_data in data_package["games"].items(): Utils.store_data_package_for_checksum(game, game_data) + def consume_network_item_groups(self): + data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]} + current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {}) + if self.game in current_cache: + current_cache[self.game].update(data) + else: + current_cache[self.game] = data + Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache) + + def consume_network_location_groups(self): + data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]} + current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {}) + if self.game in current_cache: + current_cache[self.game].update(data) + else: + current_cache[self.game] = data + Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache) + # data storage def set_notify(self, *keys: str) -> None: @@ -937,6 +1020,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict): 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}") + if ctx.game: + game = ctx.game + else: + game = ctx.slot_info[ctx.slot][1] + ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}") + ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}") msgs = [] if ctx.locations_checked: msgs.append({"cmd": "LocationChecks", @@ -1017,11 +1106,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.stored_data.update(args["keys"]) if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]: ctx.ui.update_hints() + if f"_read_item_name_groups_{ctx.game}" in args["keys"]: + ctx.consume_network_item_groups() + if f"_read_location_name_groups_{ctx.game}" in args["keys"]: + ctx.consume_network_location_groups() elif cmd == "SetReply": ctx.stored_data[args["key"]] = args["value"] if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: ctx.ui.update_hints() + elif f"_read_item_name_groups_{ctx.game}" == args["key"]: + ctx.consume_network_item_groups() + elif f"_read_location_name_groups_{ctx.game}" == args["key"]: + ctx.consume_network_location_groups() elif args["key"].startswith("EnergyLink"): ctx.current_energy_link_value = args["value"] if ctx.ui: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..46393aab9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# hadolint global ignore=SC1090,SC1091 + +# Source +FROM scratch AS release +WORKDIR /release +ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip + +# Enemizer +FROM alpine:3.21 AS enemizer +ARG TARGETARCH +WORKDIR /release +COPY --from=release /release/Enemizer.zip . + +# No release for arm architecture. Skip. +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + apk add unzip=6.0-r15 --no-cache && \ + unzip -u Enemizer.zip -d EnemizerCLI && \ + chmod -R 777 EnemizerCLI; \ + else touch EnemizerCLI; fi + +# Cython builder stage +FROM python:3.12 AS cython-builder + +WORKDIR /build + +# Copy and install requirements first (better caching) +COPY requirements.txt WebHostLib/requirements.txt + +RUN pip install --no-cache-dir -r \ + WebHostLib/requirements.txt \ + setuptools + +COPY _speedups.pyx . +COPY intset.h . + +RUN cythonize -b -i _speedups.pyx + +# Archipelago +FROM python:3.12-slim AS archipelago +ARG TARGETARCH +ENV VIRTUAL_ENV=/opt/venv +ENV PYTHONUNBUFFERED=1 +WORKDIR /app + +# Install requirements +# hadolint ignore=DL3008 +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git \ + gcc=4:12.2.0-3 \ + libc6-dev \ + libtk8.6=8.6.13-2 \ + g++=4:12.2.0-3 \ + curl && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create and activate venv +RUN python -m venv $VIRTUAL_ENV; \ + . $VIRTUAL_ENV/bin/activate + +# Copy and install requirements first (better caching) +COPY WebHostLib/requirements.txt WebHostLib/requirements.txt + +RUN pip install --no-cache-dir -r \ + WebHostLib/requirements.txt \ + gunicorn==23.0.0 + +COPY . . + +COPY --from=cython-builder /build/*.so ./ + +# Run ModuleUpdate +RUN python ModuleUpdate.py -y + +# Purge unneeded packages +RUN apt-get purge -y \ + git \ + gcc \ + libc6-dev \ + g++ && \ + apt-get autoremove -y + +# Copy necessary components +COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI + +# No release for arm architecture. Skip. +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + cp -r /tmp/EnemizerCLI EnemizerCLI; \ + fi; \ + rm -rf /tmp/EnemizerCLI + +# Define health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:${PORT:-80} || exit 1 + +# Ensure no runtime ModuleUpdate. +ENV SKIP_REQUIREMENTS_UPDATE=true + +ENTRYPOINT [ "python", "WebHost.py" ] diff --git a/Fill.py b/Fill.py index d0a42c07eb..1cc1278f4b 100644 --- a/Fill.py +++ b/Fill.py @@ -116,6 +116,13 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati else: # we filled all reachable spots. if swap: + # Keep a cache of previous safe swap states that might be usable to sweep from to produce the next + # swap state, instead of sweeping from `base_state` each time. + previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque() + # Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive + # single_player_placement=True pre-fills which can go through more than 10 states in some seeds. + max_swap_base_state_cache_length = 3 + # try swapping this item with previously placed items in a safe way then in an unsafe way swap_attempts = ((i, location, unsafe) for unsafe in (False, True) @@ -130,9 +137,30 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati location.item = None placed_item.location = None - swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool, - multiworld.get_filled_locations(item.player) - if single_player_placement else None) + + for previous_safe_swap_state in previous_safe_swap_state_cache: + # If a state has already checked the location of the swap, then it cannot be used. + if location not in previous_safe_swap_state.advancements: + # Previous swap states will have collected all items in `item_pool`, so the new + # `swap_state` can skip having to collect them again. + # Previous swap states will also have already checked many locations, making the sweep + # faster. + swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (), + multiworld.get_filled_locations(item.player) + if single_player_placement else None) + break + else: + # No previous swap_state was usable as a base state to sweep from, so create a new one. + swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool, + multiworld.get_filled_locations(item.player) + if single_player_placement else None) + # Unsafe states should not be added to the cache because they have collected `placed_item`. + if not unsafe: + if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length: + # Remove the oldest cached state. + previous_safe_swap_state_cache.pop() + # Add the new state to the start of the cache. + previous_safe_swap_state_cache.appendleft(swap_state) # 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. @@ -330,7 +358,12 @@ def fast_fill(multiworld: MultiWorld, return item_pool[placing:], fill_locations[placing:] -def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): +def accessibility_corrections(multiworld: MultiWorld, + state: CollectionState, + locations: list[Location], + pool: list[Item] | None = None) -> None: + if pool is None: + pool = [] maximum_exploration_state = sweep_from_pool(state, pool) minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} @@ -450,6 +483,12 @@ def distribute_early_items(multiworld: MultiWorld, def distribute_items_restrictive(multiworld: MultiWorld, panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None: + assert all(item.location is None for item in multiworld.itempool), ( + "At the start of distribute_items_restrictive, " + "there are items in the multiworld itempool that are already placed on locations:\n" + f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}" + ) + fill_locations = sorted(multiworld.get_unfilled_locations()) multiworld.random.shuffle(fill_locations) # get items to distribute @@ -492,18 +531,48 @@ def distribute_items_restrictive(multiworld: MultiWorld, single_player = multiworld.players == 1 and not multiworld.groups if prioritylocations: + regular_progression = [] + deprioritized_progression = [] + for item in progitempool: + if item.deprioritized: + deprioritized_progression.append(item) + else: + regular_progression.append(item) + # "priority fill" - maximum_exploration_state = sweep_from_pool(multiworld.state) - fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, + # try without deprioritized items in the mix at all. This means they need to be collected into state first. + priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression) + fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression, single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority", one_item_per_player=True, allow_partial=True) - if prioritylocations: + if prioritylocations and regular_progression: # retry with one_item_per_player off because some priority fills can fail to fill with that optimization - maximum_exploration_state = sweep_from_pool(multiworld.state) - fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, - single_player_placement=single_player, swap=False, on_place=mark_for_locking, - name="Priority Retry", one_item_per_player=False) + # deprioritized items are still not in the mix, so they need to be collected into state first. + priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression) + fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression, + single_player_placement=single_player, swap=False, on_place=mark_for_locking, + name="Priority Retry", one_item_per_player=False, allow_partial=True) + + if prioritylocations and deprioritized_progression: + # There are no more regular progression items that can be placed on any priority locations. + # We'd still prefer to place deprioritized progression items on priority locations over filler items. + # Since we're leaving out the remaining regular progression now, we need to collect it into state first. + priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression) + fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression, + single_player_placement=single_player, swap=False, on_place=mark_for_locking, + name="Priority Retry 2", one_item_per_player=True, allow_partial=True) + + if prioritylocations and deprioritized_progression: + # retry with deprioritized items AND without one_item_per_player optimisation + # Since we're leaving out the remaining regular progression now, we need to collect it into state first. + priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression) + fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression, + single_player_placement=single_player, swap=False, on_place=mark_for_locking, + name="Priority Retry 3", one_item_per_player=False) + + # restore original order of progitempool + progitempool[:] = [item for item in progitempool if not item.location] accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) defaultlocations = prioritylocations + defaultlocations @@ -890,7 +959,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo worlds = set() for listed_world in target_world: if listed_world not in world_name_lookup: - failed(f"Cannot place item to {target_world}'s world as that world does not exist.", + failed(f"Cannot place item to {listed_world}'s world as that world does not exist.", block.force) continue worlds.add(world_name_lookup[listed_world]) @@ -923,9 +992,9 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo if isinstance(locations, str): locations = [locations] - locations_from_groups: list[str] = [] resolved_locations: list[Location] = [] for target_player in worlds: + locations_from_groups: list[str] = [] world_locations = multiworld.get_unfilled_locations(target_player) for group in multiworld.worlds[target_player].location_name_groups: if group in locations: @@ -937,13 +1006,16 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo count = block.count if not count: - count = len(new_block.items) + count = (min(len(new_block.items), len(new_block.resolved_locations)) + if new_block.resolved_locations else len(new_block.items)) if isinstance(count, int): count = {"min": count, "max": count} if "min" not in count: count["min"] = 0 if "max" not in count: - count["max"] = len(new_block.items) + count["max"] = (min(len(new_block.items), len(new_block.resolved_locations)) + if new_block.resolved_locations else len(new_block.items)) + new_block.count = count plando_blocks[player].append(new_block) diff --git a/Launcher.py b/Launcher.py index 8b533a505f..5720012cf9 100644 --- a/Launcher.py +++ b/Launcher.py @@ -11,6 +11,7 @@ Additional components can be added to worlds.LauncherComponents.components. import argparse import logging import multiprocessing +import os import shlex import subprocess import sys @@ -41,13 +42,17 @@ def open_host_yaml(): if is_linux: exe = which('sensible-editor') or which('gedit') or \ which('xdg-open') or which('gnome-open') or which('kde-open') - subprocess.Popen([exe, file]) elif is_macos: exe = which("open") - subprocess.Popen([exe, file]) else: webbrowser.open(file) + return + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + subprocess.Popen([exe, file], env=env) def open_patch(): suffixes = [] @@ -92,7 +97,11 @@ def open_folder(folder_path): return if exe: - subprocess.Popen([exe, folder_path]) + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + subprocess.Popen([exe, folder_path], env=env) else: logging.warning(f"No file browser available to open {folder_path}") @@ -104,45 +113,48 @@ def update_settings(): components.extend([ # Functions - Component("Open host.yaml", func=open_host_yaml), - Component("Open Patch", func=open_patch), - Component("Generate Template Options", func=generate_yamls), - Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")), - Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), + Component("Open host.yaml", func=open_host_yaml, + description="Open the host.yaml file to change settings for generation, games, and more."), + Component("Open Patch", func=open_patch, + description="Open a patch file, downloaded from the room page or provided by the host."), + Component("Generate Template Options", func=generate_yamls, + description="Generate template YAMLs for currently installed games."), + Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"), + description="Open archipelago.gg in your browser."), + Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"), + description="Join the Discord server to play public multiworlds, report issues, or just chat!"), Component("Unrated/18+ Discord Server", icon="discord", - func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), - Component("Browse Files", func=browse_files), + func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"), + description="Find unrated and 18+ games in the After Dark Discord server."), + Component("Browse Files", func=browse_files, + description="Open the Archipelago installation folder in your file browser."), ]) -def handle_uri(path: str, launch_args: tuple[str, ...]) -> None: +def handle_uri(path: str) -> tuple[list[Component], Component]: url = urllib.parse.urlparse(path) queries = urllib.parse.parse_qs(url.query) - launch_args = (path, *launch_args) - client_component = [] + client_components = [] text_client_component = None game = queries["game"][0] for component in components: if component.supports_uri and component.game_name == game: - client_component.append(component) + client_components.append(component) elif component.display_name == "Text Client": text_client_component = component + return client_components, text_client_component - if not client_component: - run_component(text_client_component, *launch_args) - return - else: - from kvui import ButtonsPrompt - component_options = { - text_client_component.display_name: text_client_component, - **{component.display_name: component for component in client_component} - } - popup = ButtonsPrompt("Connect to Multiworld", - "Select client to open and connect with.", - lambda component_name: run_component(component_options[component_name], *launch_args), - *component_options.keys()) - popup.open() +def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None: + from kvui import ButtonsPrompt + component_options = { + component.display_name: component for component in component_list + } + popup = ButtonsPrompt("Connect to Multiworld", + "Select client to open and connect with.", + lambda component_name: run_component(component_options[component_name], *launch_args), + *component_options.keys()) + popup.open() def identify(path: None | str) -> tuple[None | str, None | Component]: @@ -184,7 +196,8 @@ def get_exe(component: str | Component) -> Sequence[str] | None: def launch(exe, in_terminal=False): if in_terminal: if is_windows: - subprocess.Popen(['start', *exe], shell=True) + # intentionally using a window title with a space so it gets quoted and treated as a title + subprocess.Popen(["start", "Running Archipelago", *exe], shell=True) return elif is_linux: terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm') @@ -212,7 +225,7 @@ def create_shortcut(button: Any, component: Component) -> None: refresh_components: Callable[[], None] | None = None -def run_gui(path: str, args: Any) -> None: +def run_gui(launch_components: list[Component], args: Any) -> None: from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) from kivy.properties import ObjectProperty from kivy.core.window import Window @@ -245,12 +258,12 @@ def run_gui(path: str, args: Any) -> None: cards: list[LauncherCard] current_filter: Sequence[str | Type] | None - def __init__(self, ctx=None, path=None, args=None): + def __init__(self, ctx=None, components=None, args=None): self.title = self.base_title + " " + Utils.__version__ self.ctx = ctx self.icon = r"data/icon.png" self.favorites = [] - self.launch_uri = path + self.launch_components = components self.launch_args = args self.cards = [] self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) @@ -372,9 +385,9 @@ def run_gui(path: str, args: Any) -> None: return self.top_screen def on_start(self): - if self.launch_uri: - handle_uri(self.launch_uri, self.launch_args) - self.launch_uri = None + if self.launch_components: + build_uri_popup(self.launch_components, self.launch_args) + self.launch_components = None self.launch_args = None @staticmethod @@ -415,7 +428,7 @@ def run_gui(path: str, args: Any) -> None: for filter in self.current_filter)) super().on_stop() - Launcher(path=path, args=args).run() + Launcher(components=launch_components, args=args).run() # avoiding Launcher reference leak # and don't try to do something with widgets after window closed @@ -442,7 +455,15 @@ def main(args: argparse.Namespace | dict | None = None): path = args.get("Patch|Game|Component|url", None) if path is not None: - if not path.startswith("archipelago://"): + if path.startswith("archipelago://"): + args["args"] = (path, *args.get("args", ())) + # add the url arg to the passthrough args + components, text_client_component = handle_uri(path) + if not components: + args["component"] = text_client_component + else: + args['launch_components'] = [text_client_component, *components] + else: file, component = identify(path) if file: args['file'] = file @@ -458,7 +479,7 @@ def main(args: argparse.Namespace | dict | None = None): elif "component" in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: - run_gui(path, args.get("args", ())) + run_gui(args.get("launch_components", None), args.get("args", ())) if __name__ == '__main__': diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 963557e8da..4816210ff5 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past" WINDOW_MIN_HEIGHT = 525 WINDOW_MIN_WIDTH = 425 + class AdjusterWorld(object): class AdjusterSubWorld(object): def __init__(self, random): @@ -40,7 +41,6 @@ class AdjusterWorld(object): def __init__(self, sprite_pool): import random self.sprite_pool = {1: sprite_pool} - self.per_slot_randoms = {1: random} self.worlds = {1: self.AdjusterSubWorld(random)} @@ -49,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): def _get_help_string(self, action): return textwrap.dedent(action.help) + # See argparse.BooleanOptionalAction class BooleanOptionalActionWithDisable(argparse.Action): def __init__(self, @@ -364,10 +365,10 @@ def run_sprite_update(): logging.info("Done updating sprites") -def update_sprites(task, on_finish=None): +def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"): resultmessage = "" successful = True - sprite_dir = user_path("data", "sprites", "alttpr") + sprite_dir = user_path("data", "sprites", "alttp", "remote") os.makedirs(sprite_dir, exist_ok=True) ctx = get_cert_none_ssl_context() @@ -377,11 +378,11 @@ def update_sprites(task, on_finish=None): on_finish(successful, resultmessage) try: - task.update_status("Downloading alttpr sprites list") - with urlopen('https://alttpr.com/sprites', context=ctx) as response: + task.update_status("Downloading remote sprites list") + with urlopen(repository_url, context=ctx) as response: sprites_arr = json.loads(response.read().decode("utf-8")) except Exception as e: - resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) + resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) successful = False task.queue_event(finished) return @@ -389,13 +390,13 @@ def update_sprites(task, on_finish=None): try: task.update_status("Determining needed sprites") current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')] - alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) + remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) for sprite in sprites_arr if sprite["author"] != "Nintendo"] - needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if + needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if filename not in current_sprites] - alttpr_filenames = [filename for (_, filename) in alttpr_sprites] - obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames] + remote_filenames = [filename for (_, filename) in remote_sprites] + obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames] except Exception as e: resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % ( type(e).__name__, e) @@ -447,7 +448,7 @@ def update_sprites(task, on_finish=None): successful = False if successful: - resultmessage = "alttpr sprites updated successfully" + resultmessage = "Remote sprites updated successfully" task.queue_event(finished) @@ -868,7 +869,7 @@ class SpriteSelector(): def open_custom_sprite_dir(_evt): open_file(self.custom_sprite_dir) - alttpr_frametitle = Label(self.window, text='ALTTPR Sprites') + remote_frametitle = Label(self.window, text='Remote Sprites') custom_frametitle = Frame(self.window) title_text = Label(custom_frametitle, text="Custom Sprites") @@ -877,8 +878,8 @@ class SpriteSelector(): title_link.pack(side=LEFT) title_link.bind("", open_custom_sprite_dir) - self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, - 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.') + self.icon_section(remote_frametitle, self.remote_sprite_dir, + 'Remote sprites not found. Click "Update remote sprites" to download them.') self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.') if not randomOnEvent: @@ -891,11 +892,18 @@ class SpriteSelector(): button = Button(frame, text="Browse for file...", command=self.browse_for_sprite) button.pack(side=RIGHT, padx=(5, 0)) - button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites) + button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites) button.pack(side=RIGHT, padx=(5, 0)) + + repository_label = Label(frame, text='Sprite Repository:') + self.repository_url = StringVar(frame, "https://alttpr.com/sprites") + repository_entry = Entry(frame, textvariable=self.repository_url) + repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1) + repository_label.pack(side=RIGHT, expand=False, padx=(0, 5)) + button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite) - button.pack(side=LEFT,padx=(0,5)) + button.pack(side=LEFT, padx=(0, 5)) button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite) button.pack(side=LEFT, padx=(0, 5)) @@ -1055,7 +1063,7 @@ class SpriteSelector(): for i, button in enumerate(frame.buttons): button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow) - def update_alttpr_sprites(self): + def update_remote_sprites(self): # need to wrap in try catch. We don't want errors getting the json or downloading the files to break us. self.window.destroy() self.parent.update() @@ -1068,7 +1076,8 @@ class SpriteSelector(): messagebox.showerror("Sprite Updater", resultmessage) SpriteSelector(self.parent, self.callback, self.adjuster) - BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish) + BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", + on_finish, self.repository_url.get()) def browse_for_sprite(self): sprite = filedialog.askopenfilename( @@ -1158,12 +1167,13 @@ class SpriteSelector(): os.makedirs(self.custom_sprite_dir) @property - def alttpr_sprite_dir(self): - return user_path("data", "sprites", "alttpr") + def remote_sprite_dir(self): + return user_path("data", "sprites", "alttp", "remote") @property def custom_sprite_dir(self): - return user_path("data", "sprites", "custom") + return user_path("data", "sprites", "alttp", "custom") + def get_image_for_sprite(sprite, gif_only: bool = False): if not sprite.valid: diff --git a/MMBN3Client.py b/MMBN3Client.py index 4945d49221..31c6b309b8 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -286,16 +286,14 @@ async def gba_sync_task(ctx: MMBN3Context): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.gba_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue async def run_game(romfile): - options = Utils.get_options().get("mmbn3_options", None) - if options is None: - auto_start = True - else: - auto_start = options.get("rom_start", True) - if auto_start: + from worlds.mmbn3 import MMBN3World + auto_start = MMBN3World.settings.rom_start + if auto_start is True: import webbrowser webbrowser.open(romfile) elif os.path.isfile(auto_start): diff --git a/Main.py b/Main.py index 442c2ff404..bc2787579f 100644 --- a/Main.py +++ b/Main.py @@ -1,10 +1,11 @@ import collections +from collections.abc import Mapping import concurrent.futures import logging import os -import pickle import tempfile import time +from typing import Any import zipfile import zlib @@ -12,8 +13,9 @@ import worlds from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned +from NetUtils import convert_to_base_types from Options import StartInventoryPool -from Utils import __version__, output_path, version_tuple +from Utils import __version__, output_path, restricted_dumps, version_tuple from settings import get_settings from worlds import AutoWorld from worlds.generic.Rules import exclusion_rules, locality_rules @@ -92,6 +94,15 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) del local_early del early + # items can't be both local and non-local, prefer local + multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value + multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player]) + + # Clear non-applicable local and non-local items. + if multiworld.players == 1: + multiworld.worlds[1].options.non_local_items.value = set() + multiworld.worlds[1].options.local_items.value = set() + logger.info('Creating MultiWorld.') AutoWorld.call_all(multiworld, "create_regions") @@ -99,12 +110,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) AutoWorld.call_all(multiworld, "create_items") logger.info('Calculating Access Rules.') - - for player in multiworld.player_ids: - # items can't be both local and non-local, prefer local - multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value - multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player]) - AutoWorld.call_all(multiworld, "set_rules") for player in multiworld.player_ids: @@ -125,11 +130,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations # Set local and non-local item rules. + # This function is called so late because worlds might otherwise overwrite item_rules which are how locality works if multiworld.players > 1: locality_rules(multiworld) - else: - multiworld.worlds[1].options.non_local_items.value = set() - multiworld.worlds[1].options.local_items.value = set() multiworld.plando_item_blocks = parse_planned_blocks(multiworld) @@ -173,7 +176,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) multiworld.link_items() - if any(multiworld.item_links.values()): + if any(world.options.item_links for world in multiworld.worlds.values()): multiworld._all_state = None logger.info("Running Item Plando.") @@ -238,11 +241,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) def write_multidata(): import NetUtils from NetUtils import HintStatus - slot_data = {} - client_versions = {} - games = {} - minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions} - slot_info = {} + slot_data: dict[int, Mapping[str, Any]] = {} + client_versions: dict[int, tuple[int, int, int]] = {} + games: dict[int, str] = {} + minimum_versions: NetUtils.MinimumVersions = { + "server": AutoWorld.World.required_server_version, "clients": client_versions + } + slot_info: dict[int, NetUtils.NetworkSlot] = {} names = [[name for player, name in sorted(multiworld.player_name.items())]] for slot in multiworld.player_ids: player_world: AutoWorld.World = multiworld.worlds[slot] @@ -257,7 +262,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) group_members=sorted(group["players"])) precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int] for player, world_precollected in multiworld.precollected_items.items()} - precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))} + precollected_hints: dict[int, set[NetUtils.Hint]] = { + player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups)) + } for slot in multiworld.player_ids: slot_data[slot] = multiworld.worlds[slot].fill_slot_data() @@ -314,7 +321,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) if current_sphere: spheres.append(dict(current_sphere)) - multidata = { + multidata: NetUtils.MultiData | bytes = { "slot_data": slot_data, "slot_info": slot_info, "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, @@ -324,7 +331,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) "er_hint_data": er_hint_data, "precollected_items": precollected_items, "precollected_hints": precollected_hints, - "version": tuple(version_tuple), + "version": (version_tuple.major, version_tuple.minor, version_tuple.build), "tags": ["AP"], "minimum_versions": minimum_versions, "seed_name": multiworld.seed_name, @@ -332,9 +339,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) "datapackage": data_package, "race_mode": int(multiworld.is_race), } + # TODO: change to `"version": version_tuple` after getting better serialization AutoWorld.call_all(multiworld, "modify_multidata", multidata) - multidata = zlib.compress(pickle.dumps(multidata), 9) + for key in ("slot_data", "er_hint_data"): + multidata[key] = convert_to_base_types(multidata[key]) + + multidata = zlib.compress(restricted_dumps(multidata), 9) with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f: f.write(bytes([3])) # version of format diff --git a/MinecraftClient.py b/MinecraftClient.py deleted file mode 100644 index 3047dc540e..0000000000 --- a/MinecraftClient.py +++ /dev/null @@ -1,347 +0,0 @@ -import argparse -import json -import os -import sys -import re -import atexit -import shutil -from subprocess import Popen -from shutil import copyfile -from time import strftime -import logging - -import requests - -import Utils -from Utils import is_windows -from settings import get_settings - -atexit.register(input, "Press enter to exit.") - -# 1 or more digits followed by m or g, then optional b -max_heap_re = re.compile(r"^\d+[mMgG][bB]?$") - - -def prompt_yes_no(prompt): - yes_inputs = {'yes', 'ye', 'y'} - no_inputs = {'no', 'n'} - while True: - choice = input(prompt + " [y/n] ").lower() - if choice in yes_inputs: - return True - elif choice in no_inputs: - return False - else: - print('Please respond with "y" or "n".') - - -def find_ap_randomizer_jar(forge_dir): - """Create mods folder if needed; find AP randomizer jar; return None if not found.""" - mods_dir = os.path.join(forge_dir, 'mods') - if os.path.isdir(mods_dir): - for entry in os.scandir(mods_dir): - if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"): - logging.info(f"Found AP randomizer mod: {entry.name}") - return entry.name - return None - else: - os.mkdir(mods_dir) - logging.info(f"Created mods folder in {forge_dir}") - return None - - -def replace_apmc_files(forge_dir, apmc_file): - """Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.""" - if apmc_file is None: - return - apdata_dir = os.path.join(forge_dir, 'APData') - copy_apmc = True - if not os.path.isdir(apdata_dir): - os.mkdir(apdata_dir) - logging.info(f"Created APData folder in {forge_dir}") - for entry in os.scandir(apdata_dir): - if entry.name.endswith(".apmc") and entry.is_file(): - if not os.path.samefile(apmc_file, entry.path): - os.remove(entry.path) - logging.info(f"Removed {entry.name} in {apdata_dir}") - else: # apmc already in apdata - copy_apmc = False - if copy_apmc: - copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file))) - logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}") - - -def read_apmc_file(apmc_file): - from base64 import b64decode - - with open(apmc_file, 'r') as f: - return json.loads(b64decode(f.read())) - - -def update_mod(forge_dir, url: str): - """Check mod version, download new mod from GitHub releases page if needed. """ - ap_randomizer = find_ap_randomizer_jar(forge_dir) - os.path.basename(url) - if ap_randomizer is not None: - logging.info(f"Your current mod is {ap_randomizer}.") - else: - logging.info(f"You do not have the AP randomizer mod installed.") - - if ap_randomizer != os.path.basename(url): - logging.info(f"A new release of the Minecraft AP randomizer mod was found: " - f"{os.path.basename(url)}") - if prompt_yes_no("Would you like to update?"): - old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None - new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url)) - logging.info("Downloading AP randomizer mod. This may take a moment...") - apmod_resp = requests.get(url) - if apmod_resp.status_code == 200: - with open(new_ap_mod, 'wb') as f: - f.write(apmod_resp.content) - logging.info(f"Wrote new mod file to {new_ap_mod}") - if old_ap_mod is not None: - os.remove(old_ap_mod) - logging.info(f"Removed old mod file from {old_ap_mod}") - else: - logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).") - logging.error(f"Please report this issue on the Archipelago Discord server.") - sys.exit(1) - - -def check_eula(forge_dir): - """Check if the EULA is agreed to, and prompt the user to read and agree if necessary.""" - eula_path = os.path.join(forge_dir, "eula.txt") - if not os.path.isfile(eula_path): - # Create eula.txt - with open(eula_path, 'w') as f: - f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n") - f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n") - f.write("eula=false\n") - with open(eula_path, 'r+') as f: - text = f.read() - if 'false' in text: - # Prompt user to agree to the EULA - logging.info("You need to agree to the Minecraft EULA in order to run the server.") - logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula") - if prompt_yes_no("Do you agree to the EULA?"): - f.seek(0) - f.write(text.replace('false', 'true')) - f.truncate() - logging.info(f"Set {eula_path} to true") - else: - sys.exit(0) - - -def find_jdk_dir(version: str) -> str: - """get the specified versions jdk directory""" - for entry in os.listdir(): - if os.path.isdir(entry) and entry.startswith(f"jdk{version}"): - return os.path.abspath(entry) - - -def find_jdk(version: str) -> str: - """get the java exe location""" - - if is_windows: - jdk = find_jdk_dir(version) - jdk_exe = os.path.join(jdk, "bin", "java.exe") - if os.path.isfile(jdk_exe): - return jdk_exe - else: - jdk_exe = shutil.which(options.java) - if not jdk_exe: - jdk_exe = shutil.which("java") # try to fall back to system java - if not jdk_exe: - raise Exception("Could not find Java. Is Java installed on the system?") - return jdk_exe - - -def download_java(java: str): - """Download Corretto (Amazon JDK)""" - - jdk = find_jdk_dir(java) - if jdk is not None: - print(f"Removing old JDK...") - from shutil import rmtree - rmtree(jdk) - - print(f"Downloading Java...") - jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip" - resp = requests.get(jdk_url) - if resp.status_code == 200: # OK - print(f"Extracting...") - import zipfile - from io import BytesIO - with zipfile.ZipFile(BytesIO(resp.content)) as zf: - zf.extractall() - else: - print(f"Error downloading Java (status code {resp.status_code}).") - print(f"If this was not expected, please report this issue on the Archipelago Discord server.") - if not prompt_yes_no("Continue anyways?"): - sys.exit(0) - - -def install_forge(directory: str, forge_version: str, java_version: str): - """download and install forge""" - - java_exe = find_jdk(java_version) - if java_exe is not None: - print(f"Downloading Forge {forge_version}...") - forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar" - resp = requests.get(forge_url) - if resp.status_code == 200: # OK - forge_install_jar = os.path.join(directory, "forge_install.jar") - if not os.path.exists(directory): - os.mkdir(directory) - with open(forge_install_jar, 'wb') as f: - f.write(resp.content) - print(f"Installing Forge...") - install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory]) - install_process.wait() - os.remove(forge_install_jar) - - -def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen: - """Run the Forge server.""" - - java_exe = find_jdk(java_version) - if not os.path.isfile(java_exe): - java_exe = "java" # try to fall back on java in the PATH - - heap_arg = max_heap_re.match(heap_arg).group() - if heap_arg[-1] in ['b', 'B']: - heap_arg = heap_arg[:-1] - heap_arg = "-Xmx" + heap_arg - - os_args = "win_args.txt" if is_windows else "unix_args.txt" - args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args) - forge_args = [] - with open(args_file) as argfile: - for line in argfile: - forge_args.extend(line.strip().split(" ")) - - args = [java_exe, heap_arg, *forge_args, "-nogui"] - logging.info(f"Running Forge server: {args}") - os.chdir(forge_dir) - return Popen(args) - - -def get_minecraft_versions(version, release_channel="release"): - version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json" - resp = requests.get(version_file_endpoint) - local = False - if resp.status_code == 200: # OK - try: - data = resp.json() - except requests.exceptions.JSONDecodeError: - logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).") - local = True - else: - logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).") - local = True - - if local: - with open(Utils.user_path("minecraft_versions.json"), 'r') as f: - data = json.load(f) - else: - with open(Utils.user_path("minecraft_versions.json"), 'w') as f: - json.dump(data, f) - - try: - if version: - return next(filter(lambda entry: entry["version"] == version, data[release_channel])) - else: - return resp.json()[release_channel][0] - except (StopIteration, KeyError): - logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.") - if release_channel != "release": - logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file") - else: - logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.") - sys.exit(0) - - -def is_correct_forge(forge_dir) -> bool: - if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)): - return True - return False - - -if __name__ == '__main__': - Utils.init_logging("MinecraftClient") - parser = argparse.ArgumentParser() - parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)") - parser.add_argument('--install', '-i', dest='install', default=False, action='store_true', - help="Download and install Java and the Forge server. Does not launch the client afterwards.") - parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store', - help="Specify release channel to use.") - parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store', - help="specify java version.") - parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store', - help="specify forge version. (Minecraft Version-Forge Version)") - parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store', - help="specify Mod data version to download.") - - args = parser.parse_args() - apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None - - # Change to executable's working directory - os.chdir(os.path.abspath(os.path.dirname(sys.argv[0]))) - - options = get_settings().minecraft_options - channel = args.channel or options.release_channel - apmc_data = None - data_version = args.data_version or None - - if apmc_file is None and not args.install: - apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),)) - - if apmc_file is not None and data_version is None: - apmc_data = read_apmc_file(apmc_file) - data_version = apmc_data.get('client_version', '') - - versions = get_minecraft_versions(data_version, channel) - - forge_dir = options.forge_directory - max_heap = options.max_heap_size - forge_version = args.forge or versions["forge"] - java_version = args.java or versions["java"] - mod_url = versions["url"] - java_dir = find_jdk_dir(java_version) - - if args.install: - if is_windows: - print("Installing Java") - download_java(java_version) - if not is_correct_forge(forge_dir): - print("Installing Minecraft Forge") - install_forge(forge_dir, forge_version, java_version) - else: - print("Correct Forge version already found, skipping install.") - sys.exit(0) - - if apmc_data is None: - raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})") - - if is_windows: - if java_dir is None or not os.path.isdir(java_dir): - if prompt_yes_no("Did not find java directory. Download and install java now?"): - download_java(java_version) - java_dir = find_jdk_dir(java_version) - if java_dir is None or not os.path.isdir(java_dir): - raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.") - - if not is_correct_forge(forge_dir): - if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"): - install_forge(forge_dir, forge_version, java_version) - if not os.path.isdir(forge_dir): - raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.") - - if not max_heap_re.match(max_heap): - raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.") - - update_mod(forge_dir, mod_url) - replace_apmc_files(forge_dir, apmc_file) - check_eula(forge_dir) - server_process = run_forge_server(forge_dir, java_version, max_heap) - server_process.wait() diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 04cf25ea55..e6ac570e58 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -16,7 +16,11 @@ elif sys.version_info < (3, 10, 1): raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.") # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) -_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process()) +_skip_update = bool( + getattr(sys, "frozen", False) or + multiprocessing.parent_process() or + os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes") +) update_ran = _skip_update diff --git a/MultiServer.py b/MultiServer.py index f12f327c3f..11a9e394c6 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -43,7 +43,7 @@ import NetUtils import Utils from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ - SlotType, LocationStore, Hint, HintStatus + SlotType, LocationStore, MultiData, Hint, HintStatus from BaseClasses import ItemClassification @@ -445,7 +445,7 @@ class Context: raise Utils.VersionException("Incompatible multidata.") return restricted_loads(zlib.decompress(data[1:])) - def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any], + def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any], use_embedded_server_options: bool): self.read_data = {} @@ -546,6 +546,7 @@ class Context: def _save(self, exit_save: bool = False) -> bool: try: + # Does not use Utils.restricted_dumps because we'd rather make a save than not make one encoded_save = pickle.dumps(self.get_save()) with open(self.save_filename, "wb") as f: f.write(zlib.compress(encoded_save)) @@ -752,7 +753,7 @@ class Context: return self.player_names[team, slot] def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False, - recipients: typing.Sequence[int] = None): + persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None): """Send and remember hints.""" if only_new: hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] @@ -767,8 +768,9 @@ class Context: if not hint.local and data not in concerns[hint.finding_player]: concerns[hint.finding_player].append(data) - # only remember hints that were not already found at the time of creation - if not hint.found: + # For !hint use cases, only hints that were not already found at the time of creation should be remembered + # For LocationScouts use-cases, all hints should be remembered + if not hint.found or persist_even_if_found: # since hints are bidirectional, finding player and receiving player, # we can check once if hint already exists if hint not in self.hints[team, hint.finding_player]: @@ -1946,10 +1948,52 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location, HintStatus.HINT_UNSPECIFIED)) locs.append(NetworkItem(target_item, location, target_player, flags)) - ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2) + ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True) if locs and create_as_hint: ctx.save() await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) + + elif cmd == 'CreateHints': + location_player = args.get("player", client.slot) + locations = args["locations"] + status = args.get("status", HintStatus.HINT_UNSPECIFIED) + + if not locations: + await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments", + "text": "CreateHints: No locations specified.", "original_cmd": cmd}]) + + hints = [] + + for location in locations: + if location_player != client.slot and location not in ctx.locations[location_player]: + error_text = ( + "CreateHints: One or more of the locations do not exist for the specified off-world player. " + "Please refrain from hinting other slot's locations that you don't know contain your items." + ) + await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments", + "text": error_text, "original_cmd": cmd}]) + return + + target_item, item_player, flags = ctx.locations[location_player][location] + + if client.slot not in ctx.slot_set(item_player): + if status != HintStatus.HINT_UNSPECIFIED: + error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.' + await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments", + "text": error_text, "original_cmd": cmd}]) + return + + if client.slot != location_player: + error_text = "CreateHints: Can only create hints for own items or own locations." + await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments", + "text": error_text, "original_cmd": cmd}]) + return + + hints += collect_hint_location_id(ctx, client.team, location_player, location, status) + + # As of writing this code, only_new=True does not update status for existing hints + ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True) + ctx.save() elif cmd == 'UpdateHint': location = args["location"] diff --git a/NetUtils.py b/NetUtils.py index f2ae2a63a0..45279183f6 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence import typing import enum import warnings @@ -83,7 +84,7 @@ class NetworkSlot(typing.NamedTuple): name: str game: str type: SlotType - group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group + group_members: Sequence[int] = () # only populated if type == group class NetworkItem(typing.NamedTuple): @@ -106,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any: return obj +_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"] + + +def convert_to_base_types(obj: typing.Any) -> _base_types: + if isinstance(obj, (tuple, list, set, frozenset)): + return tuple(convert_to_base_types(o) for o in obj) + elif isinstance(obj, dict): + return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()} + elif obj is None or type(obj) in (str, int, float, bool): + return obj + # unwrap simple types to their base, such as StrEnum + elif isinstance(obj, str): + return str(obj) + elif isinstance(obj, int): + return int(obj) + elif isinstance(obj, float): + return float(obj) + else: + raise Exception(f"Cannot handle {type(obj)}") + + _encode = JSONEncoder( ensure_ascii=False, check_circular=False, @@ -450,6 +472,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu location_id not in checked]) +class MinimumVersions(typing.TypedDict): + server: tuple[int, int, int] + clients: dict[int, tuple[int, int, int]] + + +class GamesPackage(typing.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 + + +class DataPackage(typing.TypedDict): + games: dict[str, GamesPackage] + + +class MultiData(typing.TypedDict): + slot_data: dict[int, Mapping[str, typing.Any]] + slot_info: dict[int, NetworkSlot] + connect_names: dict[str, tuple[int, int]] + locations: dict[int, dict[int, tuple[int, int, int]]] + checks_in_area: dict[int, dict[str, int | list[int]]] + server_options: dict[str, object] + er_hint_data: dict[int, dict[int, str]] + precollected_items: dict[int, list[int]] + precollected_hints: dict[int, set[Hint]] + version: tuple[int, int, int] + tags: list[str] + minimum_versions: MinimumVersions + seed_name: str + spheres: list[dict[int, set[int]]] + datapackage: dict[str, GamesPackage] + race_mode: int + + if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub LocationStore = _LocationStore else: diff --git a/OoTClient.py b/OoTClient.py index 571300ed36..2b0c7e4966 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -277,6 +277,7 @@ async def n64_sync_task(ctx: OoTContext): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.n64_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue diff --git a/Options.py b/Options.py index 3d08c5f003..47d6c2d387 100644 --- a/Options.py +++ b/Options.py @@ -494,6 +494,30 @@ class Choice(NumericOption): else: raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") + def __lt__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} < {other}" + other = self.options[other] + return super(Choice, self).__lt__(other) + + def __gt__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} > {other}" + other = self.options[other] + return super(Choice, self).__gt__(other) + + def __le__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} <= {other}" + other = self.options[other] + return super(Choice, self).__le__(other) + + def __ge__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} >= {other}" + other = self.options[other] + return super(Choice, self).__ge__(other) + __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ @@ -865,13 +889,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin return ", ".join(f"{key}: {v}" for key, v in value.items()) def __getitem__(self, item: str) -> typing.Any: - return self.value.__getitem__(item) + return self.value[item] def __iter__(self) -> typing.Iterator[str]: - return self.value.__iter__() + return iter(self.value) def __len__(self) -> int: - return self.value.__len__() + return len(self.value) # __getitem__ fallback fails for Counters, so we define this explicitly def __contains__(self, item) -> bool: @@ -1067,10 +1091,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): yield from self.value def __getitem__(self, index: typing.SupportsIndex) -> PlandoText: - return self.value.__getitem__(index) + return self.value[index] def __len__(self) -> int: - return self.value.__len__() + return len(self.value) class ConnectionsMeta(AssembleOptions): @@ -1094,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple): entrance: str exit: str - direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped + direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped percentage: int = 100 @@ -1217,7 +1241,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect connection.exit) for connection in value]) def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection: - return self.value.__getitem__(index) + return self.value[index] def __iter__(self) -> typing.Iterator[PlandoConnection]: yield from self.value @@ -1315,6 +1339,7 @@ class CommonOptions(metaclass=OptionsMetaProperty): will be returned as a sorted list. """ assert option_names, "options.as_dict() was used without any option names." + assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need." option_results = {} for option_name in option_names: if option_name not in type(self).type_hints: @@ -1524,9 +1549,11 @@ class PlandoItems(Option[typing.List[PlandoItem]]): f"dictionary, not {type(items)}") locations = item.get("locations", []) if not locations: - locations = item.get("location", ["Everywhere"]) + locations = item.get("location", []) if locations: count = 1 + else: + locations = ["Everywhere"] if isinstance(locations, str): locations = [locations] if not isinstance(locations, list): @@ -1641,7 +1668,7 @@ class OptionGroup(typing.NamedTuple): item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints, - StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks] + StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems] """ Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group. If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to diff --git a/README.md b/README.md index 84e62b1528..44c44d72b4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ Currently, the following games are supported: * The Legend of Zelda: A Link to the Past * Factorio -* Minecraft * Subnautica * Risk of Rain 2 * The Legend of Zelda: Ocarina of Time @@ -15,7 +14,6 @@ Currently, the following games are supported: * Super Metroid * Secret of Evermore * Final Fantasy -* Rogue Legacy * VVVVVV * Raft * Super Mario 64 @@ -42,7 +40,6 @@ Currently, the following games are supported: * The Messenger * Kingdom Hearts 2 * The Legend of Zelda: Link's Awakening DX -* Clique * Adventure * DLC Quest * Noita @@ -82,6 +79,8 @@ Currently, the following games are supported: * The Legend of Zelda: The Wind Waker * Jak and Daxter: The Precursor Legacy * Super Mario Land 2: 6 Golden Coins +* shapez +* Paint 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 1156bf6040..d8bc05841f 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -18,6 +18,7 @@ from json import loads, dumps from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser import Utils +from settings import Settings from Utils import async_start from MultiServer import mark_raw if typing.TYPE_CHECKING: @@ -285,7 +286,7 @@ class SNESState(enum.IntEnum): def launch_sni() -> None: - sni_path = Utils.get_settings()["sni_options"]["sni_path"] + sni_path = Settings.sni_options.sni_path if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) @@ -668,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None: async def run_game(romfile: str) -> None: - auto_start = typing.cast(typing.Union[bool, str], - Utils.get_settings()["sni_options"].get("snes_rom_start", True)) + auto_start = Settings.sni_options.snes_rom_start if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/Utils.py b/Utils.py index b38809ba1b..b7616b57b1 100644 --- a/Utils.py +++ b/Utils.py @@ -47,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.6.2" +__version__ = "0.6.3" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -166,6 +166,10 @@ def home_path(*path: str) -> str: os.symlink(home_path.cached_path, legacy_home_path) else: os.makedirs(home_path.cached_path, 0o700, exist_ok=True) + elif sys.platform == 'darwin': + import platformdirs + home_path.cached_path = platformdirs.user_data_dir("Archipelago", False) + os.makedirs(home_path.cached_path, 0o700, exist_ok=True) else: # not implemented home_path.cached_path = local_path() # this will generate the same exceptions we got previously @@ -177,7 +181,7 @@ def user_path(*path: str) -> str: """Returns either local_path or home_path based on write permissions.""" if hasattr(user_path, "cached_path"): pass - elif os.access(local_path(), os.W_OK): + elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()): user_path.cached_path = local_path() else: user_path.cached_path = home_path() @@ -226,7 +230,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: from shutil import which open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) assert open_command, "Didn't find program for open_file! Please report this together with system details." - subprocess.call([open_command, filename]) + + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + subprocess.call([open_command, filename], env=env) # from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes @@ -404,13 +413,23 @@ def get_adjuster_settings(game_name: str) -> Namespace: @cache_argsless def get_unique_identifier(): - uuid = persistent_load().get("client", {}).get("uuid", None) + common_path = cache_path("common.json") + if os.path.exists(common_path): + with open(common_path) as f: + common_file = json.load(f) + uuid = common_file.get("uuid", None) + else: + common_file = {} + uuid = None + if uuid: return uuid - import uuid - uuid = uuid.getnode() - persistent_store("client", "uuid", uuid) + from uuid import uuid4 + uuid = str(uuid4()) + common_file["uuid"] = uuid + with open(common_path, "w") as f: + json.dump(common_file, f, separators=(",", ":")) return uuid @@ -433,6 +452,7 @@ class RestrictedUnpickler(pickle.Unpickler): if module == "builtins" and name in safe_builtins: return getattr(builtins, name) # used by OptionCounter + # necessary because the actual Options class instances are pickled when transfered to WebHost generation pool if module == "collections" and name == "Counter": return collections.Counter # used by MultiServer -> savegame/multidata @@ -463,6 +483,18 @@ def restricted_loads(s: bytes) -> Any: return RestrictedUnpickler(io.BytesIO(s)).load() +def restricted_dumps(obj: Any) -> bytes: + """Helper function analogous to pickle.dumps().""" + s = pickle.dumps(obj) + # Assert that the string can be successfully loaded by restricted_loads + try: + restricted_loads(s) + except pickle.UnpicklingError as e: + raise pickle.PicklingError(e) from e + + return s + + class ByValue: """ Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. @@ -708,25 +740,30 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: res.put(open_filename(*args)) +def _run_for_stdout(*args: str): + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None + + def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: logging.info(f"Opening file input dialog for {title}.") - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_linux: # prefer native dialog from shutil import which kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) + return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) selection = (f"--filename={suggest}",) if suggest else () - return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -760,21 +797,18 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_linux: # prefer native dialog from shutil import which kdialog = which("kdialog") if kdialog: - return run(kdialog, f"--title={title}", "--getexistingdirectory", + return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory", os.path.abspath(suggest) if suggest else ".") zenity = which("zenity") if zenity: z_filters = ("--directory",) selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () - return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -801,9 +835,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def messagebox(title: str, text: str, error: bool = False) -> None: - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_kivy_running(): from kvui import MessageBox MessageBox(title, text, error).open() @@ -814,10 +845,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None: from shutil import which kdialog = which("kdialog") if kdialog: - return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) + return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) zenity = which("zenity") if zenity: - return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") + return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") elif is_windows: import ctypes @@ -922,8 +953,7 @@ def _extend_freeze_support() -> None: # Handle the first process that MP will create if ( len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( - 'from multiprocessing.semaphore_tracker import main', # Py<3.8 - 'from multiprocessing.resource_tracker import main', # Py>=3.8 + 'from multiprocessing.resource_tracker import main', 'from multiprocessing.forkserver import main' )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) ): diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 934cc2498d..74086cb884 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -61,30 +61,43 @@ cache = Cache() Compress(app) +def to_python(value): + return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) + + +def to_url(value): + return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') + + class B64UUIDConverter(BaseConverter): def to_python(self, value): - return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) + return to_python(value) def to_url(self, value): - return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') + return to_url(value) # short UUID app.url_map.converters["suuid"] = B64UUIDConverter -app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') +app.jinja_env.filters["suuid"] = to_url app.jinja_env.filters["title_sorted"] = title_sorted def register(): """Import submodules, triggering their registering on flask routing. Note: initializes worlds subsystem.""" + import importlib + + from werkzeug.utils import find_modules # has automatic patch integration import worlds.Files app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container from WebHostLib.customserver import run_server_process - # to trigger app routing picking up on it - from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session + for module in find_modules("WebHostLib", include_packages=True): + importlib.import_module(module) + + from . import api app.register_blueprint(api.api_endpoints) diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py index 5a66d1e693..7bcbdbcf19 100644 --- a/WebHostLib/api/generate.py +++ b/WebHostLib/api/generate.py @@ -1,11 +1,11 @@ import json -import pickle from uuid import UUID from flask import request, session, url_for from markupsafe import Markup from pony.orm import commit +from Utils import restricted_dumps from WebHostLib import app from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.generate import get_meta @@ -56,7 +56,7 @@ def generate_api(): "detail": results}, 400 else: gen = Generation( - options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), + options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), # convert to json compatible meta=json.dumps(meta), state=STATE_QUEUED, owner=session["_id"]) diff --git a/WebHostLib/api/room.py b/WebHostLib/api/room.py index 9337975695..78623bbe3e 100644 --- a/WebHostLib/api/room.py +++ b/WebHostLib/api/room.py @@ -3,6 +3,7 @@ from uuid import UUID from flask import abort, url_for +from WebHostLib import to_url import worlds.Files from . import api_endpoints, get_players from ..models import Room @@ -33,7 +34,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]: downloads.append(slot_download) return { - "tracker": room.tracker, + "tracker": to_url(room.tracker), "players": get_players(room.seed), "last_port": room.last_port, "last_activity": room.last_activity, diff --git a/WebHostLib/api/user.py b/WebHostLib/api/user.py index 2524cc40a6..59c8e57283 100644 --- a/WebHostLib/api/user.py +++ b/WebHostLib/api/user.py @@ -1,6 +1,7 @@ from flask import session, jsonify from pony.orm import select +from WebHostLib import to_url from WebHostLib.models import Room, Seed from . import api_endpoints, get_players @@ -10,13 +11,13 @@ def get_rooms(): response = [] for room in select(room for room in Room if room.owner == session["_id"]): response.append({ - "room_id": room.id, - "seed_id": room.seed.id, + "room_id": to_url(room.id), + "seed_id": to_url(room.seed.id), "creation_time": room.creation_time, "last_activity": room.last_activity, "last_port": room.last_port, "timeout": room.timeout, - "tracker": room.tracker, + "tracker": to_url(room.tracker), }) return jsonify(response) @@ -26,7 +27,7 @@ def get_seeds(): response = [] for seed in select(seed for seed in Seed if seed.owner == session["_id"]): response.append({ - "seed_id": seed.id, + "seed_id": to_url(seed.id), "creation_time": seed.creation_time, "players": get_players(seed), }) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index b330146277..719963e375 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -164,9 +164,6 @@ def autogen(config: dict): Thread(target=keep_running, name="AP_Autogen").start() -multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {} - - class MultiworldInstance(): def __init__(self, config: dict, id: int): self.room_ids = set() diff --git a/WebHostLib/check.py b/WebHostLib/check.py index 4e0cf1178f..b8e1fd8755 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,7 +1,7 @@ import os import zipfile import base64 -from typing import Union, Dict, Set, Tuple +from collections.abc import Set from flask import request, flash, redirect, url_for, render_template from markupsafe import Markup @@ -43,7 +43,7 @@ def mysterycheck(): return redirect(url_for("check"), 301) -def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: +def get_yaml_data(files) -> dict[str, str] | str | Markup: options = {} for uploaded_file in files: if banned_file(uploaded_file.filename): @@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: return options -def roll_options(options: Dict[str, Union[dict, str]], +def roll_options(options: dict[str, dict | str], plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \ - Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]: + tuple[dict[str, str | bool], dict[str, dict]]: plando_options = PlandoOptions.from_set(set(plando_options)) - results = {} - rolled_results = {} + results: dict[str, str | bool] = {} + rolled_results: dict[str, dict] = {} for filename, text in options.items(): try: if type(text) is dict: diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 2ebb40d673..156c12523d 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -129,7 +129,7 @@ class WebHostContext(Context): else: row = GameDataPackage.get(checksum=game_data["checksum"]) if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete - game_data_packages[game] = Utils.restricted_loads(row.data) + game_data_packages[game] = restricted_loads(row.data) continue else: self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") @@ -159,6 +159,7 @@ class WebHostContext(Context): @db_session def _save(self, exit_save: bool = False) -> bool: room = Room.get(id=self.room_id) + # Does not use Utils.restricted_dumps because we'd rather make a save than not make one room.multisave = pickle.dumps(self.get_save()) # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index a09ca70171..388a6dc73c 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int): else: import io - if slot_data.game == "Minecraft": - from worlds.minecraft import mc_update_output - fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" - data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port) - return send_file(io.BytesIO(data), as_attachment=True, download_name=fname) - elif slot_data.game == "Factorio": + if slot_data.game == "Factorio": with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: for name in zf.namelist(): if name.endswith("info.json"): diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 34033a0854..02f5a0379a 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -1,12 +1,12 @@ import concurrent.futures import json import os -import pickle import random import tempfile import zipfile from collections import Counter -from typing import Any, Dict, List, Optional, Union, Set +from pickle import PicklingError +from typing import Any from flask import flash, redirect, render_template, request, session, url_for from pony.orm import commit, db_session @@ -14,7 +14,7 @@ from pony.orm import commit, db_session from BaseClasses import get_seed, seeddigits from Generate import PlandoOptions, handle_name from Main import main as ERmain -from Utils import __version__ +from Utils import __version__, restricted_dumps from WebHostLib import app from settings import ServerOptions, GeneratorOptions from worlds.alttp.EntranceRandomizer import parse_arguments @@ -23,8 +23,8 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID from .upload import upload_zip_to_db -def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: - plando_options: Set[str] = set() +def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]: + plando_options: set[str] = set() for substr in ("bosses", "items", "connections", "texts"): if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): plando_options.add(substr) @@ -73,7 +73,7 @@ def generate(race=False): return render_template("generate.html", race=race, version=__version__) -def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]): +def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): results, gen_options = roll_options(options, set(meta["plando_options"])) if any(type(result) == str for result in results.values()): @@ -83,12 +83,18 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]) f"If you have a larger group, please generate it yourself and upload it.") return redirect(url_for(request.endpoint, **(request.view_args or {}))) elif len(gen_options) >= app.config["JOB_THRESHOLD"]: - gen = Generation( - options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), - # convert to json compatible - meta=json.dumps(meta), - state=STATE_QUEUED, - owner=session["_id"]) + try: + gen = Generation( + options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), + # convert to json compatible + meta=json.dumps(meta), + state=STATE_QUEUED, + owner=session["_id"]) + except PicklingError as e: + from .autolauncher import handle_generation_failure + handle_generation_failure(e) + return render_template("seedError.html", seed_error=("PicklingError: " + str(e))) + commit() return redirect(url_for("wait_seed", seed=gen.id)) @@ -104,9 +110,9 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]) return redirect(url_for("view_seed", seed=seed_id)) -def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): - if not meta: - meta: Dict[str, Any] = {} +def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): + if meta is None: + meta = {} meta.setdefault("server_options", {}).setdefault("hint_cost", 10) race = meta.setdefault("generator_options", {}).setdefault("race", False) diff --git a/WebHostLib/lttpsprites.py b/WebHostLib/lttpsprites.py index 1b8ee4cf48..9d780b13e1 100644 --- a/WebHostLib/lttpsprites.py +++ b/WebHostLib/lttpsprites.py @@ -14,7 +14,7 @@ def update_sprites_lttp(): from LttPAdjuster import update_sprites # Target directories - input_dir = user_path("data", "sprites", "alttpr") + input_dir = user_path("data", "sprites", "alttp", "remote") output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index a9cd33dd6d..4e6bf25df0 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,4 +1,4 @@ -flask>=3.1.0 +flask>=3.1.1 werkzeug>=3.1.3 pony>=0.7.19 waitress>=3.0.2 diff --git a/WebHostLib/static/assets/minecraftTracker.js b/WebHostLib/static/assets/minecraftTracker.js deleted file mode 100644 index a698214b8d..0000000000 --- a/WebHostLib/static/assets/minecraftTracker.js +++ /dev/null @@ -1,49 +0,0 @@ -window.addEventListener('load', () => { - // Reload tracker every 15 seconds - const url = window.location; - setInterval(() => { - const ajax = new XMLHttpRequest(); - ajax.onreadystatechange = () => { - if (ajax.readyState !== 4) { return; } - - // Create a fake DOM using the returned HTML - const domParser = new DOMParser(); - const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html'); - - // Update item tracker - document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML; - // Update only counters in the location-table - let counters = document.getElementsByClassName('counter'); - const fakeCounters = fakeDOM.getElementsByClassName('counter'); - for (let i = 0; i < counters.length; i++) { - counters[i].innerHTML = fakeCounters[i].innerHTML; - } - }; - ajax.open('GET', url); - ajax.send(); - }, 15000) - - // Collapsible advancement sections - const categories = document.getElementsByClassName("location-category"); - for (let i = 0; i < categories.length; i++) { - let hide_id = categories[i].id.split('-')[0]; - if (hide_id == 'Total') { - continue; - } - categories[i].addEventListener('click', function() { - // Toggle the advancement list - document.getElementById(hide_id).classList.toggle("hide"); - // Change text of the header - const tab_header = document.getElementById(hide_id+'-header').children[0]; - const orig_text = tab_header.innerHTML; - let new_text; - if (orig_text.includes("▼")) { - new_text = orig_text.replace("▼", "▲"); - } - else { - new_text = orig_text.replace("▲", "▼"); - } - tab_header.innerHTML = new_text; - }); - } -}); diff --git a/WebHostLib/static/styles/minecraftTracker.css b/WebHostLib/static/styles/minecraftTracker.css deleted file mode 100644 index 224cdcdc55..0000000000 --- a/WebHostLib/static/styles/minecraftTracker.css +++ /dev/null @@ -1,102 +0,0 @@ -#player-tracker-wrapper{ - margin: 0; -} - -#inventory-table{ - border-top: 2px solid #000000; - border-left: 2px solid #000000; - border-right: 2px solid #000000; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - padding: 3px 3px 10px; - width: 384px; - background-color: #42b149; -} - -#inventory-table td{ - width: 40px; - height: 40px; - text-align: center; - vertical-align: middle; -} - -#inventory-table img{ - height: 100%; - max-width: 40px; - max-height: 40px; - filter: grayscale(100%) contrast(75%) brightness(30%); -} - -#inventory-table img.acquired{ - filter: none; -} - -#inventory-table div.counted-item { - position: relative; -} - -#inventory-table div.item-count { - position: absolute; - color: white; - font-family: "Minecraftia", monospace; - font-weight: bold; - bottom: 0; - right: 0; -} - -#location-table{ - width: 384px; - border-left: 2px solid #000000; - border-right: 2px solid #000000; - border-bottom: 2px solid #000000; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - background-color: #42b149; - padding: 0 3px 3px; - font-family: "Minecraftia", monospace; - font-size: 14px; - cursor: default; -} - -#location-table th{ - vertical-align: middle; - text-align: left; - padding-right: 10px; -} - -#location-table td{ - padding-top: 2px; - padding-bottom: 2px; - line-height: 20px; -} - -#location-table td.counter { - text-align: right; - font-size: 14px; -} - -#location-table td.toggle-arrow { - text-align: right; -} - -#location-table tr#Total-header { - font-weight: bold; -} - -#location-table img{ - height: 100%; - max-width: 30px; - max-height: 30px; -} - -#location-table tbody.locations { - font-size: 12px; -} - -#location-table td.location-name { - padding-left: 16px; -} - -.hide { - display: none; -} diff --git a/WebHostLib/stats.py b/WebHostLib/stats.py index 36545ac96f..2ce25c2cc7 100644 --- a/WebHostLib/stats.py +++ b/WebHostLib/stats.py @@ -1,4 +1,3 @@ -import typing from collections import Counter, defaultdict from colorsys import hsv_to_rgb from datetime import datetime, timedelta, date @@ -18,21 +17,23 @@ from .models import Room PLOT_WIDTH = 600 -def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str], - typing.DefaultDict[datetime.date, typing.Dict[str, int]]]: - games_played = defaultdict(Counter) - total_games = Counter() +def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]: + games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter) + total_games: Counter[str] = Counter() cutoff = date.today() - timedelta(days=30) room: Room for room in select(room for room in Room if room.creation_time >= cutoff): for slot in room.seed.slots: if slot.game in known_games: - total_games[slot.game] += 1 - games_played[room.creation_time.date()][slot.game] += 1 + current_game = slot.game + else: + current_game = "Other" + total_games[current_game] += 1 + games_played[room.creation_time.date()][current_game] += 1 return total_games, games_played -def get_color_palette(colors_needed: int) -> typing.List[RGB]: +def get_color_palette(colors_needed: int) -> list[RGB]: colors = [] # colors_needed +1 to prevent first and last color being too close to each other colors_needed += 1 @@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]: return colors -def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]], - game: str, color: RGB) -> figure: +def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure: occurences = [] days = [day for day, game_data in all_games_data.items() if game_data[game]] for day in days: @@ -84,7 +84,7 @@ def stats(): days = sorted(games_played) color_palette = get_color_palette(len(total_games)) - game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)} + game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)} for game in sorted(total_games): occurences = [] diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index 0416658dde..9a16bce1d3 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -26,15 +26,15 @@ {{ patch.game }} {% if patch.data %} - {% if patch.game == "Minecraft" %} - - Download APMC File... - {% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %} + {% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %} Download APV6 File... {% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %} Download APSM64EX File... + {% elif patch.game == "Factorio" %} + + Download Factorio Mod... {% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %} Download Patch File... diff --git a/WebHostLib/templates/tracker__Minecraft.html b/WebHostLib/templates/tracker__Minecraft.html deleted file mode 100644 index 248f2778bd..0000000000 --- a/WebHostLib/templates/tracker__Minecraft.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - {{ player_name }}'s Tracker - - - - - - - {# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #} -
- Switch To Generic Tracker -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
{{ pearls_count }}
-
-
-
- -
{{ scrap_count }}
-
-
-
- -
{{ shard_count }}
-
-
- - {% for area in checks_done %} - - - - - - {% for location in location_info[area] %} - - - - - {% endfor %} - - {% endfor %} -
{{ area }} {{'▼' if area != 'Total'}}{{ checks_done[area] }} / {{ checks_in_area[area] }}
{{ location }}{{ '✔' if location_info[area][location] else '' }}
-
- - diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 3748de97a4..4b92f4b416 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -706,127 +706,6 @@ if "A Link to the Past" in network_data_package["games"]: _multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker _player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker -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", - } - - 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"] - } - - 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 = { diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 45b26b175e..48885e9cc6 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -1,4 +1,3 @@ -import base64 import json import pickle import typing @@ -14,9 +13,8 @@ from pony.orm.core import TransactionIntegrityError import schema import MultiServer -from NetUtils import SlotType +from NetUtils import GamesPackage, 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 @@ -119,9 +117,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s # AP Container elif handler: data = zfile.open(file, "r").read() - patch = handler(BytesIO(data)) - patch.read() - files[patch.player] = data + with zipfile.ZipFile(BytesIO(data)) as container: + player = json.loads(container.open("archipelago.json").read())["player"] + files[player] = data # Spoiler elif file.filename.endswith(".txt"): @@ -135,11 +133,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s flash("Could not load multidata. File may be corrupted or incompatible.") multidata = None - # Minecraft - elif file.filename.endswith(".apmc"): - data = zfile.open(file, "r").read() - metadata = json.loads(base64.b64decode(data).decode("utf-8")) - files[metadata["player_id"]] = data # Factorio elif file.filename.endswith(".zip"): diff --git a/Zelda1Client.py b/Zelda1Client.py index 4473b3f3c7..9753621ef0 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -333,6 +333,7 @@ async def nes_sync_task(ctx: ZeldaContext): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.nes_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue diff --git a/data/client.kv b/data/client.kv index 53000dfe41..ed63df135d 100644 --- a/data/client.kv +++ b/data/client.kv @@ -24,9 +24,20 @@ : ripple_color: app.theme_cls.primaryColor ripple_duration_in_fast: 0.2 -: - ripple_color: app.theme_cls.primaryColor - ripple_duration_in_fast: 0.2 +: + on_release: app.screens.switch_screens(self) + + MDNavigationItemLabel: + text: root.text + theme_text_color: "Custom" + text_color_active: self.theme_cls.primaryColor + text_color_normal: 1, 1, 1, 1 + # indicator is on icon only for some reason + canvas.before: + Color: + rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor + Rectangle: + size: root.size : adaptive_height: True theme_font_size: "Custom" diff --git a/data/lua/connector_bizhawk_generic.lua b/data/lua/connector_bizhawk_generic.lua index c2e8f91c0d..387ca2c6f3 100644 --- a/data/lua/connector_bizhawk_generic.lua +++ b/data/lua/connector_bizhawk_generic.lua @@ -365,18 +365,14 @@ request_handlers = { ["PREFERRED_CORES"] = function (req) local res = {} local preferred_cores = client.getconfig().PreferredCores + local systems_enumerator = preferred_cores.Keys:GetEnumerator() 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 + + while systems_enumerator:MoveNext() do + res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current] + end return res end, diff --git a/data/lua/connector_mmbn3.lua b/data/lua/connector_mmbn3.lua index fce38a4c11..c89f428fe5 100644 --- a/data/lua/connector_mmbn3.lua +++ b/data/lua/connector_mmbn3.lua @@ -477,7 +477,7 @@ function main() elseif (curstate == STATE_UNINITIALIZED) then -- If we're uninitialized, attempt to make the connection. if (frame % 120 == 0) then - server:settimeout(2) + server:settimeout(120) local client, timeout = server:accept() if timeout == nil then print('Initial Connection Made') diff --git a/data/mcicon.ico b/data/mcicon.ico deleted file mode 100644 index 3df0be06f8..0000000000 Binary files a/data/mcicon.ico and /dev/null differ diff --git a/data/options.yaml b/data/options.yaml index 3fbe25a921..f2621124c8 100644 --- a/data/options.yaml +++ b/data/options.yaml @@ -46,7 +46,9 @@ requires: {{ yaml_dump(game) }}: {%- for group_name, group_options in option_groups.items() %} - # {{ group_name }} + ##{% for _ in group_name %}#{% endfor %}## + # {{ group_name }} # + ##{% for _ in group_name %}#{% endfor %}## {%- for option_key, option in group_options.items() %} {{ option_key }}: diff --git a/data/sprites/alttpr/.gitignore b/data/sprites/remote/.gitignore similarity index 100% rename from data/sprites/alttpr/.gitignore rename to data/sprites/remote/.gitignore diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000000..1472667442 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,61 @@ +services: + multiworld: + # Build only once. Web service uses the same image build + build: + context: .. + # Name image for use in web service + image: archipelago-base + # Use locally-built image + pull_policy: never + # Launch main process without website hosting (config override) + entrypoint: python WebHost.py --config_override selflaunch.yaml + volumes: + # Mount application volume + - app_volume:/app + + # Mount configs + - ./example_config.yaml:/app/config.yaml + - ./example_selflaunch.yaml:/app/selflaunch.yaml + + # Expose on host network for access to dynamically mapped ports + network_mode: host + + # No Healthcheck in place yet for multiworld + healthcheck: + test: ["NONE"] + web: + # Use image build by multiworld service + image: archipelago-base + # Use locally-built image + pull_policy: never + # Launch gunicorn targeting WebHost application + entrypoint: gunicorn -c gunicorn.conf.py + volumes: + # Mount application volume + - app_volume:/app + + # Mount configs + - ./example_config.yaml:/app/config.yaml + - ./example_gunicorn.conf.py:/app/gunicorn.conf.py + environment: + # Bind gunicorn on 8000 + - PORT=8000 + + nginx: + image: nginx:stable-alpine + volumes: + # Mount application volume + - app_volume:/app + + # Mount config + - ./example_nginx.conf:/etc/nginx/nginx.conf + ports: + # Nginx listening internally on port 80 -- mapped to 8080 on host + - 8080:80 + depends_on: + - web + +volumes: + # Share application directory amongst multiworld and web services + # (for access to log files and the like), and nginx (for static files) + app_volume: diff --git a/deploy/example_config.yaml b/deploy/example_config.yaml new file mode 100644 index 0000000000..d74f7f238f --- /dev/null +++ b/deploy/example_config.yaml @@ -0,0 +1,10 @@ +# Refer to ../docs/webhost configuration sample.yaml + +# We'll be hosting VIA gunicorn +SELFHOST: false +# We'll start a separate process for rooms and generators +SELFLAUNCH: false + +# Host Address. This is the address encoded into the patch that will be used for client auto-connect. +# Set as your local IP (192.168.x.x) to serve over LAN. +HOST_ADDRESS: localhost diff --git a/deploy/example_gunicorn.conf.py b/deploy/example_gunicorn.conf.py new file mode 100644 index 0000000000..49f153df67 --- /dev/null +++ b/deploy/example_gunicorn.conf.py @@ -0,0 +1,19 @@ +workers = 2 +threads = 2 +wsgi_app = "WebHost:get_app()" +accesslog = "-" +access_log_format = ( + '%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' +) +worker_class = "gthread" # "sync" | "gthread" +forwarded_allow_ips = "*" +loglevel = "info" + +""" +You can programatically set values. +For example, set number of workers to half of the cpu count: + +import multiprocessing + +workers = multiprocessing.cpu_count() / 2 +""" diff --git a/deploy/example_nginx.conf b/deploy/example_nginx.conf new file mode 100644 index 0000000000..b0c0e8e5a0 --- /dev/null +++ b/deploy/example_nginx.conf @@ -0,0 +1,64 @@ +worker_processes 1; + +user nobody nogroup; +# 'user nobody nobody;' for systems with 'nobody' as a group instead +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; # increase if you have lots of clients + accept_mutex off; # set to 'on' if nginx worker_processes > 1 + # 'use epoll;' to enable for Linux 2.6+ + # 'use kqueue;' to enable for FreeBSD, OSX + use epoll; +} + +http { + include mime.types; + # fallback in case we can't determine a type + default_type application/octet-stream; + access_log /var/log/nginx/access.log combined; + sendfile on; + + upstream app_server { + # fail_timeout=0 means we always retry an upstream even if it failed + # to return a good HTTP response + + # for UNIX domain socket setups + # server unix:/tmp/gunicorn.sock fail_timeout=0; + + # for a TCP configuration + server web:8000 fail_timeout=0; + } + + server { + # use 'listen 80 deferred;' for Linux + # use 'listen 80 accept_filter=httpready;' for FreeBSD + listen 80 deferred; + client_max_body_size 4G; + + # set the correct host(s) for your site + # server_name example.com www.example.com; + + keepalive_timeout 5; + + # path for static files + root /app/WebHostLib; + + location / { + # checks for static file, if not found proxy to app + try_files $uri @proxy_to_app; + } + + location @proxy_to_app { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + + proxy_pass http://app_server; + } + } +} diff --git a/deploy/example_selflaunch.yaml b/deploy/example_selflaunch.yaml new file mode 100644 index 0000000000..41149dc18a --- /dev/null +++ b/deploy/example_selflaunch.yaml @@ -0,0 +1,13 @@ +# Refer to ../docs/webhost configuration sample.yaml + +# We'll be hosting VIA gunicorn +SELFHOST: false +# Start room and generator processes +SELFLAUNCH: true +JOB_THRESHOLD: 0 + +# Maximum concurrent world gens +GENERATORS: 3 + +# Rooms will be spread across multiple processes +HOSTERS: 4 diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 2289daad07..85b31683aa 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -48,9 +48,6 @@ # Civilization VI /worlds/civ6/ @hesto2 -# Clique -/worlds/clique/ @ThePhar - # Dark Souls III /worlds/dark_souls_3/ @Marechal-L @nex3 @@ -121,9 +118,6 @@ # The Messenger /worlds/messenger/ @alwaysintreble -# Minecraft -/worlds/minecraft/ @KonoTyran @espeon65536 - # Mega Man 2 /worlds/mm2/ @Silvris @@ -142,6 +136,9 @@ # Overcooked! 2 /worlds/overcooked2/ @toasterparty +# Paint +/worlds/paint/ @MarioManTAW + # Pokemon Emerald /worlds/pokemon_emerald/ @Zunawe @@ -151,15 +148,15 @@ # Raft /worlds/raft/ @SunnyBat -# Rogue Legacy -/worlds/rogue_legacy/ @ThePhar - # Risk of Rain 2 /worlds/ror2/ @kindasneaki # Saving Princess /worlds/saving_princess/ @LeonarthCG +# shapez +/worlds/shapez/ @BlastSlimey + # Shivers /worlds/shivers/ @GodlFire @korydondzila diff --git a/docs/deploy using containers.md b/docs/deploy using containers.md new file mode 100644 index 0000000000..6db38d443f --- /dev/null +++ b/docs/deploy using containers.md @@ -0,0 +1,92 @@ +# Deploy Using Containers + +If you just want to play and there is a compiled version available on the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases), use that version. +To build the full Archipelago software stack, refer to [Running From Source](running%20from%20source.md). +Follow these steps to build and deploy a containerized instance of the web host software, optionally integrating [Gunicorn](https://gunicorn.org/) WSGI HTTP Server running behind the [nginx](https://nginx.org/) reverse proxy. + + +## Building the Container Image + +What you'll need: + * A container runtime engine such as: + * [Docker](https://www.docker.com/) (Version 23.0 or later) + * [Podman](https://podman.io/) (version 4.0 or later) + * For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details. + * The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`. + +Starting from the root repository directory, the standalone Archipelago image can be built and run with the command: +`docker build -t archipelago .` +Or: +`podman build -t archipelago .` + +It is recommended to tag the image using `-t` to more easily identify the image and run it. + + +## Running the Container + +Running the container can be performed using: +`docker run --network host archipelago` +Or: +`podman run --network host archipelago` + +The Archipelago web host requires access to multiple ports in order to host game servers simultaneously. To simplify configuration for this purpose, specify `--network host`. + +Given the default configuration, the website will be accessible at the hostname/IP address (localhost if run locally) of the machine being deployed to, at port 80. It can be configured by creating a YAML file and mapping a volume to the container when running initially: +`docker run archipelago --network host -v /path/to/config.yaml:/app/config.yaml` +See `docs/webhost configuration sample.yaml` for example. + + +## Using Docker Compose + +An example [docker compose](../deploy/docker-compose.yml) file can be found in [deploy](../deploy), along with example configuration files used by the services it orchestrates. Using these files as-is will spin up two separate archipelago containers with special modifications to their runtime arguments, in addition to deploying an `nginx` reverse proxy container. + +To deploy in this manner, from the ["deploy"](../deploy) directory, run: +`docker compose up -d` + +### Services + +The `docker-compose.yaml` file defines three services: + * multiworld: + * Executes the main `WebHost` process, using the [example config](../deploy/example_config.yaml), and overriding with a secondary [selflaunch example config](../deploy/example_selflaunch.yaml). This is because we do not want to launch the website through this service. + * web: + * Executes `gunicorn` using its [example config](../deploy/example_gunicorn.conf.py), which will bind it to the `WebHost` application, in effect launching it. + * We mount the main [config](../deploy/example_config.yaml) without an override to specify that we are launching the website through this service. + * No ports are exposed through to the host. + * nginx: + * Serves as a reverse proxy with `web` as its upstream. + * Directs all HTTP traffic from port 80 to the upstream service. + * Exposed to the host on port 8080. This is where we can reach the website. + +### Configuration + +As these are examples, they can be copied and modified. For instance setting the value of `HOST_ADDRESS` in [example config](../deploy/example_config.yaml) to host machines local IP address, will expose the service to its local area network. + +The configuration files may be modified to handle for machine-specific optimizations, such as: + * Web pages responding too slowly + * Edit [the gunicorn config](../deploy/example_gunicorn.conf.py) to increase thread and/or worker count. + * Game generation stalls + * Increase the generator count in [selflaunch config](../deploy/example_selflaunch.yaml) + * Gameplay lags + * Increase the hoster count in [selflaunch config](../deploy/example_selflaunch.yaml) + +Changes made to `docker-compose.yaml` can be applied by running `docker compose up -d`, while those made to other files are applied by running `docker compose restart`. + + +## Windows + +It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install). + + +## Optional: A Link to the Past Enemizer + +Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an +error if it is required. +Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory: +`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`. +Enemizer is not currently available for `aarch64`. + + +## Optional: Git + +Building the image requires a local copy of the ArchipelagoMW source code. +Refer to [Running From Source](running%20from%20source.md#optional-git). diff --git a/docs/network diagram/network diagram.md b/docs/network diagram/network diagram.md index d660e8889e..2d0a1174f5 100644 --- a/docs/network diagram/network diagram.md +++ b/docs/network diagram/network diagram.md @@ -117,12 +117,6 @@ flowchart LR %% Java Based Games subgraph Java JM[Mod with Archipelago.MultiClient.Java] - subgraph Minecraft - MCS[Minecraft Forge Server] - JMC[Any Java Minecraft Clients] - MCS <-- TCP --> JMC - end - JM <-- Forge Mod Loader --> MCS end AS <-- WebSockets --> JM @@ -131,10 +125,8 @@ flowchart LR NM[Mod with Archipelago.MultiClient.Net] subgraph FNA/XNA TS[Timespinner] - RL[Rogue Legacy] end NM <-- TsRandomizer --> TS - NM <-- RogueLegacyRandomizer --> RL subgraph Unity ROR[Risk of Rain 2] SN[Subnautica] @@ -183,4 +175,4 @@ flowchart LR FMOD <--> FMAPI end CC <-- Integrated --> FC -``` \ No newline at end of file +``` diff --git a/docs/network protocol.md b/docs/network protocol.md index 8c07ff10fd..b40cf31b85 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -276,6 +276,7 @@ These packets are sent purely from client to server. They are not accepted by cl * [Sync](#Sync) * [LocationChecks](#LocationChecks) * [LocationScouts](#LocationScouts) +* [CreateHints](#CreateHints) * [UpdateHint](#UpdateHint) * [StatusUpdate](#StatusUpdate) * [Say](#Say) @@ -294,7 +295,7 @@ Sent by the client to initiate a connection to an Archipelago game session. | password | str | If the game session requires a password, it should be passed here. | | game | str | The name of the game the client is playing. Example: `A Link to the Past` | | name | str | The player name for this client. | -| uuid | str | Unique identifier for player client. | +| uuid | str | Unique identifier for player. Cached in the user cache \Archipelago\Cache\common.json | | version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. | | items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. | | tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) | @@ -339,7 +340,8 @@ Sent to the server to retrieve the items that are on a specified list of locatio Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup. LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points. -This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value. +This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value. +Note that LocationScouts with a non-zero `create_as_hint` value will _always_ create a **persistent** hint (listed in the Hints tab of concerning players' TextClients), even if the location was already found. If this is not desired behavior, you need to prevent sending LocationScouts with `create_as_hint` for already found locations in your client-side code. #### Arguments | Name | Type | Notes | @@ -347,6 +349,21 @@ This is useful in cases where an item appears in the game world, such as 'ledge | locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. | | create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint.
If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. | +### CreateHints + +Sent to the server to create hints for a specified list of locations. +Hints that already exist will be silently skipped and their status will not be updated. + +When creating hints for another slot's locations, the packet will fail if any of those locations don't contain items for the requesting slot. +When creating hints for your own slot's locations, non-existing locations will silently be skipped. + +#### Arguments +| Name | Type | Notes | +| ---- | ---- | ----- | +| locations | list\[int\] | The ids of the locations to create hints for. | +| player | int | The ID of the player whose locations are being hinted for. Defaults to the requesting slot. | +| status | [HintStatus](#HintStatus) | If included, sets the status of the hint to this status. Defaults to `HINT_UNSPECIFIED`. Cannot set `HINT_FOUND`. | + ### UpdateHint Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails. diff --git a/docs/settings api.md b/docs/settings api.md index ef1f20d098..d701c01758 100644 --- a/docs/settings api.md +++ b/docs/settings api.md @@ -181,10 +181,3 @@ circular / partial imports. Instead, the code should fetch from settings on dema "Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary, "global" settings could be used in global scope of worlds. - - -### APWorld Backwards Compatibility - -APWorlds that want to be compatible with both stable and dev versions, have two options: -1. use the old Utils.get_options() API until Archipelago 0.4.2 is out -2. add some sort of compatibility code to your world that mimics the new API diff --git a/docs/style.md b/docs/style.md index 81853f4172..5333155db9 100644 --- a/docs/style.md +++ b/docs/style.md @@ -29,6 +29,10 @@ * New classes, attributes, and methods in core code should have docstrings that follow [reST style](https://peps.python.org/pep-0287/). * Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier. +* [Match statements](https://docs.python.org/3/tutorial/controlflow.html#tut-match) + may be used instead of `if`-`elif` if they result in nicer code, or they actually use pattern matching. + Beware of the performance: they are not `goto`s, but `if`-`elif` under the hood, and you may have less control. When + in doubt, just don't use it. ## Markdown diff --git a/docs/webhost api.md b/docs/webhost api.md new file mode 100644 index 0000000000..c8936205ec --- /dev/null +++ b/docs/webhost api.md @@ -0,0 +1,347 @@ +# API Guide + +Archipelago has a rudimentary API that can be queried by endpoints. The API is a work-in-progress and should be improved over time. + +The following API requests are formatted as: `https:///api/` + +The returned data will be formated in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable) + +Current endpoints: +- Datapackage API + - [`/datapackage`](#datapackage) + - [`/datapackage/`](#datapackagestringchecksum) + - [`/datapackage_checksum`](#datapackagechecksum) +- Generation API + - [`/generate`](#generate) + - [`/status/`](#status) +- Room API + - [`/room_status/`](#roomstatus) +- User API + - [`/get_rooms`](#getrooms) + - [`/get_seeds`](#getseeds) + + +## Datapackage Endpoints +These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations. + +### `/datapackage` + +Fetches the current datapackage from the WebHost. +You'll receive a dict named `games` that contains a named dict of every game and its data currently supported by Archipelago. +Each game will have: +- A checksum `checksum` +- A dict of item groups `item_name_groups` +- Item name to AP ID dict `item_name_to_id` +- A dict of location groups `location_name_groups` +- Location name to AP ID dict `location_name_to_id` + +Example: +``` +{ + "games": { + ... + "Clique": { + "checksum": "0271f7a80b44ba72187f92815c2bc8669cb464c7", + "item_name_groups": { + "Everything": [ + "A Cool Filler Item (No Satisfaction Guaranteed)", + "Button Activation", + "Feeling of Satisfaction" + ] + }, + "item_name_to_id": { + "A Cool Filler Item (No Satisfaction Guaranteed)": 69696967, + "Button Activation": 69696968, + "Feeling of Satisfaction": 69696969 + }, + "location_name_groups": { + "Everywhere": [ + "The Big Red Button", + "The Item on the Desk" + ] + }, + "location_name_to_id": { + "The Big Red Button": 69696969, + "The Item on the Desk": 69696968 + } + }, + ... + } +} +``` + +### `/datapackage/` + +Fetches a single datapackage by checksum. +Returns a dict of the game's data with: +- A checksum `checksum` +- A dict of item groups `item_name_groups` +- Item name to AP ID dict `item_name_to_id` +- A dict of location groups `location_name_groups` +- Location name to AP ID dict `location_name_to_id` + +Its format will be identical to the whole-datapackage endpoint (`/datapackage`), except you'll only be returned the single game's data in a dict. + +### `/datapackage_checksum` + +Fetches the checksums of the current static datapackages on the WebHost. +You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games. +Example: +``` +{ +... +"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e", +"Factorio":"a699194a9589db3ebc0d821915864b422c782f44", +... +} +``` + + +## Generation Endpoint +These endpoints are used internally for the WebHost to generate games and validate their generation. They are also used by external applications to generate games automatically. + +### `/generate` + +Submits a game to the WebHost for generation. +**This endpoint only accepts a POST HTTP request.** + +There are two ways to submit data for generation: With a file and with JSON. + +#### With a file: +Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/generate` endpoint. +If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response)) + +Example using the python requests library: +``` +file = {'file': open('Games.zip', 'rb')} +req = requests.post("https://archipelago.gg/api/generate", files=file) +``` + +#### With JSON: +Compile your weights/yaml data into a dict. Then insert that into a dict with the key `"weights"`. +Finally, submit a POST request to the `/generate` endpoint. +If the weighted options are valid, you'll be returned a successful generation response (see [Generation Response](#generation-response)) + +Example using the python requests library: +``` +data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},} +weights={"weights": data} +req = requests.post("https://archipelago.gg/api/generate", json=weights) +``` + +#### Generation Response: +##### Successful Generation: +Upon successful generation, you'll be sent a JSON dict response detailing the generation: +- The UUID of the generation `detail` +- The SUUID of the generation `encoded` +- The response text `text` +- The page that will resolve to the seed/room generation page once generation has completed `url` +- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status)) + +Example: +``` +{ + "detail": "19878f16-5a58-4b76-aab7-d6bf38be9463", + "encoded": "GYePFlpYS3aqt9a_OL6UYw", + "text": "Generation of seed 19878f16-5a58-4b76-aab7-d6bf38be9463 started successfully.", + "url": "http://archipelago.gg/wait/GYePFlpYS3aqt9a_OL6UYw", + "wait_api_url": "http://archipelago.gg/api/status/GYePFlpYS3aqt9a_OL6UYw" +} +``` + +##### Failed Generation: + +Upon failed generation, you'll be returned a single key-value pair. The key will always be `text` +The value will give you a hint as to what may have gone wrong. +- Options without tags, and a 400 status code +- Options in a string, and a 400 status code +- Invalid file/weight string, `No options found. Expected file attachment or json weights.` with a 400 status code +- Too many slots for the server to process, `Max size of multiworld exceeded` with a 409 status code + +If the generation detects a issue in generation, you'll be sent a dict with two key-value pairs (`text` and `detail`) and a 400 status code. The values will be: +- Summary of issue in `text` +- Detailed issue in `detail` + +In the event of an unhandled server exception, you'll be provided a dict with a single key `text`: +- Exception, `Uncought Exception: ` with a 500 status code + +### `/status/` + +Retrieves the status of the seed's generation. +This endpoint will return a dict with a single key-vlaue pair. The key will always be `text` +The value will tell you the status of the generation: +- Generation was completed: `Generation done` with a 201 status code +- Generation request was not found: `Generation not found` with a 404 status code +- Generation of the seed failed: `Generation failed` with a 500 status code +- Generation is in progress still: `Generation running` with a 202 status code + +## Room Endpoints +Endpoints to fetch information of the active WebHost room with the supplied room_ID. + +### `/room_status/` + +Will provide a dict of room data with the following keys: +- Tracker SUUID (`tracker`) +- A list of players (`players`) + - Each item containing a list with the Slot name and Game +- Last known hosted port (`last_port`) +- Last activity timestamp (`last_activity`) +- The room timeout counter (`timeout`) +- A list of downloads for files required for gameplay (`downloads`) + - Each item is a dict containings the download URL and slot (`slot`, `download`) + +Example: +``` +{ + "downloads": [ + { + "download": "/slot_file/kK5fmxd8TfisU5Yp_eg/1", + "slot": 1 + }, + { + "download": "/slot_file/kK5fmxd8TfisU5Yp_eg/2", + "slot": 2 + }, + { + "download": "/slot_file/kK5fmxd8TfisU5Yp_eg/3", + "slot": 3 + }, + { + "download": "/slot_file/kK5fmxd8TfisU5Yp_eg/4", + "slot": 4 + }, + { + "download": "/slot_file/kK5fmxd8TfisU5Yp_eg/5", + "slot": 5 + } + ], + "last_activity": "Fri, 18 Apr 2025 20:35:45 GMT", + "last_port": 52122, + "players": [ + [ + "Slot_Name_1", + "Ocarina of Time" + ], + [ + "Slot_Name_2", + "Ocarina of Time" + ], + [ + "Slot_Name_3", + "Ocarina of Time" + ], + [ + "Slot_Name_4", + "Ocarina of Time" + ], + [ + "Slot_Name_5", + "Ocarina of Time" + ] + ], + "timeout": 7200, + "tracker": "cf6989c0-4703-45d7-a317-2e5158431171" +} +``` + +## User Endpoints +User endpoints can get room and seed details from the current session tokens (cookies) + +### `/get_rooms` + +Retreives a list of all rooms currently owned by the session token. +Each list item will contain a dict with the room's details: +- Room SUUID (`room_id`) +- Seed SUUID (`seed_id`) +- Creation timestamp (`creation_time`) +- Last activity timestamp (`last_activity`) +- Last known AP port (`last_port`) +- Room timeout counter in seconds (`timeout`) +- Room tracker SUUID (`tracker`) + +Example: +``` +[ + { + "creation_time": "Fri, 18 Apr 2025 19:46:53 GMT", + "last_activity": "Fri, 18 Apr 2025 21:16:02 GMT", + "last_port": 52122, + "room_id": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a", + "seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6", + "timeout": 7200, + "tracker": "cf6989c0-4703-45d7-a317-2e5158431171" + }, + { + "creation_time": "Fri, 18 Apr 2025 20:36:42 GMT", + "last_activity": "Fri, 18 Apr 2025 20:36:46 GMT", + "last_port": 56884, + "room_id": "14465c05-d08e-4d28-96bd-916f994609d8", + "seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb", + "timeout": 7200, + "tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c" + } +] +``` + +### `/get_seeds` + +Retreives a list of all seeds currently owned by the session token. +Each item in the list will contain a dict with the seed's details: +- Seed SUUID (`seed_id`) +- Creation timestamp (`creation_time`) +- A list of player slots (`players`) + - Each item in the list will contain a list of the slot name and game + +Example: +``` +[ + { + "creation_time": "Fri, 18 Apr 2025 19:46:52 GMT", + "players": [ + [ + "Slot_Name_1", + "Ocarina of Time" + ], + [ + "Slot_Name_2", + "Ocarina of Time" + ], + [ + "Slot_Name_3", + "Ocarina of Time" + ], + [ + "Slot_Name_4", + "Ocarina of Time" + ], + [ + "Slot_Name_5", + "Ocarina of Time" + ] + ], + "seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6" + }, + { + "creation_time": "Fri, 18 Apr 2025 20:36:39 GMT", + "players": [ + [ + "Slot_Name_1", + "Clique" + ], + [ + "Slot_Name_2", + "Clique" + ], + [ + "Slot_Name_3", + "Clique" + ], + [ + "Slot_Name_4", + "Archipelago" + ] + ], + "seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb" + } +] +``` diff --git a/docs/world api.md b/docs/world api.md index 013b02cc20..e8932cfd83 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -258,31 +258,6 @@ another flag like "progression", it means "an especially useful progression item * `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, to not flood early spheres -### Events - -An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to -track certain logic interactions, with the Event Item being required for access in other locations or regions, but not -being "real". Since the item and location have no ID, they get dropped at the end of generation and so the server is -never made aware of them and these locations can never be checked, nor can the items be received during play. -They may also be used for making the spoiler log look nicer, i.e. by having a `"Victory"` Event Item, that -is required to finish the game. This makes it very clear when the player finishes, rather than only seeing their last -relevant Item. Events function just like any other Location, and can still have their own access rules, etc. -By convention, the Event "pair" of Location and Item typically have the same name, though this is not a requirement. -They must not exist in the `name_to_id` lookups, as they have no ID. - -The most common way to create an Event pair is to create and place the Item on the Location as soon as it's created: - -```python -from worlds.AutoWorld import World -from BaseClasses import ItemClassification -from .subclasses import MyGameLocation, MyGameItem - - -class MyGameWorld(World): - victory_loc = MyGameLocation(self.player, "Victory", None) - victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player)) -``` - ### Regions Regions are logical containers that typically hold locations that share some common access rules. If location logic is @@ -291,7 +266,7 @@ like entrance randomization in logic. Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions. -There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)), +There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L310-L311)), from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit"). ### Entrances @@ -339,6 +314,63 @@ avoiding the need for indirect conditions at the expense of performance. An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to reject the placement of an item there. +### Events (or "generation-only items/locations") + +An event item or location is one that only exists during multiworld generation; the server is never made aware of them. +Event locations can never be checked by the player, and event items cannot be received during play. + +Events are used to represent in-game actions (that aren't regular Archipelago locations) when either: + +* We want to show in the spoiler log when the player is expected to perform the in-game action. +* It's the cleanest way to represent how that in-game action impacts logic. + +Typical examples include completing the goal, defeating a boss, or flipping a switch that affects multiple areas. + +To be precise: the term "event" on its own refers to the special combination of an "event item" placed on an "event +location". Event items and locations are created the same way as normal items and locations, except that they have an +`id` of `None`, and an event item must be placed on an event location +(and vice versa). Finally, although events are often described as "fake" items and locations, it's important to +understand that they are perfectly real during generation. + +The most common way to create an event is to create the event item and the event location, then immediately call +`Location.place_locked_item()`: + +```python +victory_loc = MyGameLocation(self.player, "Defeat the Final Boss", None, final_boss_arena_region) +victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player)) +self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) +set_rule(victory_loc, lambda state: state.has("Boss Defeating Sword", self.player)) +``` + +Requiring an event to finish the game will make the spoiler log display an additional +`Defeat the Final Boss: Victory` line when the player is expected to finish, rather than only showing their last +relevant item. But events aren't just about the spoiler log; a more substantial example of using events to structure +your logic might be: + +```python +water_loc = MyGameLocation(self.player, "Water Level Switch", None, pump_station_region) +water_loc.place_locked_item(MyGameItem("Lowered Water Level", ItemClassification.progression, None, self.player)) +pump_station_region.locations.append(water_loc) +set_rule(water_loc, lambda state: state.has("Double Jump", self.player)) # the switch is really high up +... +basement_loc = MyGameLocation(self.player, "Flooded House - Basement Chest", None, flooded_house_region) +flooded_house_region.locations += [upstairs_loc, ground_floor_loc, basement_loc] +... +set_rule(basement_loc, lambda state: state.has("Lowered Water Level", self.player)) +``` + +This creates a "Lowered Water Level" event and a regular location whose access rule depends on that +event being reachable. If you made several more locations the same way, this would ensure all of those locations can +only become reachable when the event location is reachable (i.e. when the water level can be lowered), without +copy-pasting the event location's access rule and then repeatedly re-evaluating it. Also, the spoiler log will show +`Water Level Switch: Lowered Water Level` when the player is expected to do this. + +To be clear, this example could also be modeled with a second Region (perhaps "Un-Flooded House"). Or you could modify +the game so flipping that switch checks a regular AP location in addition to lowering the water level. +Events are never required, but it may be cleaner to use an event if e.g. flipping that switch affects the logic in +dozens of half-flooded areas that would all otherwise need additional Regions, and you don't want it to be a regular +location. It depends on the game. + ## Implementation ### Your World @@ -483,13 +515,14 @@ In addition, the following methods can be implemented and are called in this ord 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 the multiworld itself is still setting up before this point. + You cannot modify `local_items`, or `non_local_items` after this step. * `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. * `create_items(self)` called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and - items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions - after this step. Locations cannot be moved to different regions after this step. + items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions after + this step. Locations cannot be moved to different regions after this step. This includes event items and locations. * `set_rules(self)` called to set access and item rules on locations and entrances. * `connect_entrances(self)` @@ -501,12 +534,12 @@ In addition, the following methods can be implemented and are called in this ord called to modify item placement before, during, and after the regular fill process; all finishing before `generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there are any items that need to be filled this way, but need to be in state while you fill other items, they can be - returned from `get_prefill_items`. + returned from `get_pre_fill_items`. * `generate_output(self, output_directory: str)` 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(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that +* `fill_slot_data(self)` and `modify_multidata(self, multidata: MultiData)` can be used to modify the data that will be used by the server to host the MultiWorld. All instance methods can, optionally, have a class method defined which will be called after all instance methods are @@ -579,17 +612,10 @@ def create_items(self) -> None: # If there are two of the same item, the item has to be twice in the pool. # Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt. # Having an item in the start inventory won't remove it from the pool. - # If an item can't have duplicates it has to be excluded manually. - - # List of items to exclude, as a copy since it will be destroyed below - exclude = [item for item in self.multiworld.precollected_items[self.player]] + # If you want to do that, use start_inventory_from_pool for item in map(self.create_item, mygame_items): - if item in exclude: - exclude.remove(item) # this is destructive. create unique list above - self.multiworld.itempool.append(self.create_item("nothing")) - else: - self.multiworld.itempool.append(item) + self.multiworld.itempool.append(item) # itempool and number of locations should match up. # If this is not the case we want to fill the itempool with junk. diff --git a/entrance_rando.py b/entrance_rando.py index 5ed2cd7645..492fff32e3 100644 --- a/entrance_rando.py +++ b/entrance_rando.py @@ -52,13 +52,15 @@ class EntranceLookup: _coupled: bool _usable_exits: set[Entrance] - def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]): + def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance], targets: Iterable[Entrance]): self.dead_ends = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup() self._random = rng self._expands_graph_cache = {} self._coupled = coupled self._usable_exits = usable_exits + for target in targets: + self.add(target) def _can_expand_graph(self, entrance: Entrance) -> bool: """ @@ -121,7 +123,14 @@ class EntranceLookup: dead_end: bool, preserve_group_order: bool ) -> Iterable[Entrance]: + """ + Gets available targets for the requested groups + :param groups: The groups to find targets for + :param dead_end: Whether to find dead ends. If false, finds non-dead-ends + :param preserve_group_order: Whether to preserve the group order in the returned iterable. If true, a sequence + like AAABBB is guaranteed. If false, groups can be interleaved, e.g. BAABAB. + """ lookup = self.dead_ends if dead_end else self.others if preserve_group_order: for group in groups: @@ -132,6 +141,27 @@ class EntranceLookup: self._random.shuffle(ret) return ret + def find_target(self, name: str, group: int | None = None, dead_end: bool | None = None) -> Entrance | None: + """ + Finds a specific target in the lookup, if it is present. + + :param name: The name of the target + :param group: The target's group. Providing this will make the lookup faster, but can be omitted if it is not + known ahead of time for some reason. + :param dead_end: Whether the target is a dead end. Providing this will make the lookup faster, but can be + omitted if this is not known ahead of time (much more likely) + """ + if dead_end is None: + return (found + if (found := self.find_target(name, group, True)) + else self.find_target(name, group, False)) + lookup = self.dead_ends if dead_end else self.others + targets_to_check = lookup if group is None else lookup[group] + for target in targets_to_check: + if target.name == name: + return target + return None + def __len__(self): return len(self.dead_ends) + len(self.others) @@ -146,15 +176,18 @@ class ERPlacementState: """The world which is having its entrances randomized""" collection_state: CollectionState """The CollectionState backing the entrance randomization logic""" + entrance_lookup: EntranceLookup + """A lookup table of all unconnected ER targets""" coupled: bool """Whether entrance randomization is operating in coupled mode""" - def __init__(self, world: World, coupled: bool): + def __init__(self, world: World, entrance_lookup: EntranceLookup, coupled: bool): self.placements = [] self.pairings = [] self.world = world self.coupled = coupled self.collection_state = world.multiworld.get_all_state(False, True) + self.entrance_lookup = entrance_lookup @property def placed_regions(self) -> set[Region]: @@ -182,6 +215,7 @@ class ERPlacementState: self.collection_state.stale[self.world.player] = True self.placements.append(source_exit) self.pairings.append((source_exit.name, target_entrance.name)) + self.entrance_lookup.remove(target_entrance) def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance, usable_exits: set[Entrance]) -> bool: @@ -311,7 +345,7 @@ def randomize_entrances( preserve_group_order: bool = False, er_targets: list[Entrance] | None = None, exits: list[Entrance] | None = None, - on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None + on_connect: Callable[[ERPlacementState, list[Entrance], list[Entrance]], bool | None] | None = None ) -> ERPlacementState: """ Randomizes Entrances for a single world in the multiworld. @@ -328,14 +362,18 @@ def randomize_entrances( :param exits: The list of exits (Entrance objects with no target region) to use for randomization. Remember to be deterministic! If not provided, automatically discovers all valid exits in your world. :param on_connect: A callback function which allows specifying side effects after a placement is completed - successfully and the underlying collection state has been updated. + successfully and the underlying collection state has been updated. The arguments are + 1. The ER state + 2. The exits placed in this placement pass + 3. The entrances they were connected to. + If you use on_connect to make additional placements, you are expected to return True to inform + GER that an additional sweep is needed. """ if not world.explicit_indirect_conditions: raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order " + "to correctly analyze whether dead end regions can be required in logic.") start_time = time.perf_counter() - er_state = ERPlacementState(world, coupled) # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility perform_validity_check = True @@ -351,23 +389,25 @@ def randomize_entrances( # used when membership checks are needed on the exit list, e.g. speculative sweep exits_set = set(exits) - entrance_lookup = EntranceLookup(world.random, coupled, exits_set) - for entrance in er_targets: - entrance_lookup.add(entrance) + er_state = ERPlacementState( + world, + EntranceLookup(world.random, coupled, exits_set, er_targets), + coupled + ) # place the menu region and connected start region(s) er_state.collection_state.update_reachable_regions(world.player) def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None: - placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance) - # remove the placed targets from consideration - for entrance in removed_entrances: - entrance_lookup.remove(entrance) + placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance) # propagate new connections er_state.collection_state.update_reachable_regions(world.player) er_state.collection_state.sweep_for_advancements() if on_connect: - on_connect(er_state, placed_exits) + change = on_connect(er_state, placed_exits, paired_entrances) + if change: + er_state.collection_state.update_reachable_regions(world.player) + er_state.collection_state.sweep_for_advancements() def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool: # speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph @@ -388,12 +428,12 @@ def randomize_entrances( # check to see if we are proposing the last placement if not coupled: # in uncoupled, this check is easy as there will only be one target. - is_last_placement = len(entrance_lookup) == 1 + is_last_placement = len(er_state.entrance_lookup) == 1 else: # a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way. # if it is two way, we can safely assume that one of the targets is the logical pair of the exit. desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1 - is_last_placement = len(entrance_lookup) == desired_target_count + is_last_placement = len(er_state.entrance_lookup) == desired_target_count # if it's not the last placement, we need a sweep return not is_last_placement @@ -402,7 +442,7 @@ def randomize_entrances( placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits) for source_exit in placeable_exits: target_groups = target_group_lookup[source_exit.randomization_group] - for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): + for target_entrance in er_state.entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): # when requiring new exits, ideally we would like to make it so that every placement increases # (or keeps the same number of) reachable exits. The goal is to continue to expand the search space # so that we do not crash. In the interest of performance and bias reduction, generally, just checking @@ -420,7 +460,7 @@ def randomize_entrances( else: # no source exits had any valid target so this stage is deadlocked. retries may be implemented if early # deadlocking is a frequent issue. - lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others + lookup = er_state.entrance_lookup.dead_ends if dead_end else er_state.entrance_lookup.others # if we're in a stage where we're trying to get to new regions, we could also enter this # branch in a success state (when all regions of the preferred type have been placed, but there are still @@ -466,21 +506,21 @@ def randomize_entrances( f"All unplaced exits: {unplaced_exits}") # stage 1 - try to place all the non-dead-end entrances - while entrance_lookup.others: + while er_state.entrance_lookup.others: if not find_pairing(dead_end=False, require_new_exits=True): break # stage 2 - try to place all the dead-end entrances - while entrance_lookup.dead_ends: + while er_state.entrance_lookup.dead_ends: if not find_pairing(dead_end=True, require_new_exits=True): break # stage 3 - all the regions should be placed at this point. We now need to connect dangling edges # stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions) # doing this before the non-dead-ends is important to ensure there are enough connections to # go around - while entrance_lookup.dead_ends: + while er_state.entrance_lookup.dead_ends: find_pairing(dead_end=True, require_new_exits=False) # stage 3b - tie all the other loose ends connecting visited regions to each other - while entrance_lookup.others: + while er_state.entrance_lookup.others: find_pairing(dead_end=False, require_new_exits=False) running_time = time.perf_counter() - start_time diff --git a/inno_setup.iss b/inno_setup.iss index d9d4d7fb01..8611c849fb 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -53,10 +53,6 @@ Name: "full"; Description: "Full installation" Name: "minimal"; Description: "Minimal installation" Name: "custom"; Description: "Custom installation"; Flags: iscustom -[Components] -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; @@ -76,7 +72,6 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc [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: 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 @@ -138,11 +133,6 @@ Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; -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: ""; 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: ""; diff --git a/kvui.py b/kvui.py index 172b7e5543..e11e366d72 100644 --- a/kvui.py +++ b/kvui.py @@ -60,7 +60,10 @@ from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupporting from kivymd.uix.gridlayout import MDGridLayout from kivymd.uix.floatlayout import MDFloatLayout from kivymd.uix.boxlayout import MDBoxLayout -from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel +from kivymd.uix.navigationbar import MDNavigationBar, MDNavigationItem +from kivymd.uix.screen import MDScreen +from kivymd.uix.screenmanager import MDScreenManager + from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.menu.menu import MDDropdownTextItem from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText @@ -726,6 +729,10 @@ class MessageBox(Popup): self.height += max(0, label.height - 18) +class MDNavigationItemBase(MDNavigationItem): + text = StringProperty(None) + + class ButtonsPrompt(MDDialog): def __init__(self, title: str, text: str, response: typing.Callable[[str], None], *prompts: str, **kwargs) -> None: @@ -766,58 +773,34 @@ class ButtonsPrompt(MDDialog): ) -class ClientTabs(MDTabsSecondary): - carousel: MDTabsCarousel - lock_swiping = True +class MDScreenManagerBase(MDScreenManager): + current_tab: MDNavigationItemBase + local_screen_names: list[str] - def __init__(self, *args, **kwargs): - self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2) - super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs) - self.size_hint_y = 1 + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.local_screen_names = [] - def _check_panel_height(self, *args): - self.ids.tab_scroll.height = dp(38) - - def update_indicator( - self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None - ) -> None: - def update_indicator(*args): - indicator_pos = (0, 0) - indicator_size = (0, 0) - - item_text_object = self._get_tab_item_text_icon_object() - - if item_text_object: - indicator_pos = ( - instance.x + dp(12), - self.indicator.pos[1] - if not self._tabs_carousel - else self._tabs_carousel.height, - ) - indicator_size = ( - instance.width - dp(24), - self.indicator_height, - ) - - Animation( - pos=indicator_pos, - size=indicator_size, - d=0 if not self.indicator_anim else self.indicator_duration, - t=self.indicator_transition, - ).start(self.indicator) - - if not instance: - self.indicator.pos = (x, self.indicator.pos[1]) - self.indicator.size = (w, self.indicator_height) + def add_widget(self, widget: Widget, *args, **kwargs) -> None: + super().add_widget(widget, *args, **kwargs) + if "index" in kwargs: + self.local_screen_names.insert(kwargs["index"], widget.name) else: - Clock.schedule_once(update_indicator) + self.local_screen_names.append(widget.name) - def remove_tab(self, tab, content=None): - if content is None: - content = tab.content - self.ids.container.remove_widget(tab) - self.carousel.remove_widget(content) - self.on_size(self, self.size) + def switch_screens(self, new_tab: MDNavigationItemBase) -> None: + """ + Called whenever the user clicks a tab to switch to a different screen. + + :param new_tab: The new screen to switch to's tab. + """ + name = new_tab.text + if self.local_screen_names.index(name) > self.local_screen_names.index(self.current_screen.name): + self.transition.direction = "left" + else: + self.transition.direction = "right" + self.current = name + self.current_tab = new_tab class CommandButton(MDButton, MDTooltip): @@ -845,6 +828,9 @@ class GameManager(ThemedApp): main_area_container: MDGridLayout """ subclasses can add more columns beside the tabs """ + tabs: MDNavigationBar + screens: MDScreenManagerBase + def __init__(self, ctx: context_type): self.title = self.base_title self.ctx = ctx @@ -874,7 +860,7 @@ class GameManager(ThemedApp): @property def tab_count(self): if hasattr(self, "tabs"): - return max(1, len(self.tabs.tab_list)) + return max(1, len(self.tabs.children)) return 1 def on_start(self): @@ -914,30 +900,32 @@ class GameManager(ThemedApp): self.grid.add_widget(self.progressbar) # middle part - self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5}) - self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago"))) - self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) - for logger_name, name in - self.logging_pairs)) - self.tabs.carousel.add_widget(self.tabs.default_tab_content) + self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5}) + self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True) + # bind the method to the bar for back compatibility + self.tabs.remove_tab = self.remove_client_tab + self.screens.current_tab = self.add_client_tab( + "All" if len(self.logging_pairs) > 1 else "Archipelago", + UILog(*(logging.getLogger(logger_name) for logger_name, name in self.logging_pairs)), + ) + self.log_panels["All"] = self.screens.current_tab.content + self.screens.current_tab.active = True for logger_name, display_name in self.logging_pairs: bridge_logger = logging.getLogger(logger_name) self.log_panels[display_name] = UILog(bridge_logger) if len(self.logging_pairs) > 1: - panel = MDTabsItem(MDTabsItemText(text=display_name)) - panel.content = self.log_panels[display_name] - # show Archipelago tab if other logging is present - self.tabs.carousel.add_widget(panel.content) - self.tabs.add_widget(panel) + self.add_client_tab(display_name, self.log_panels[display_name]) - hint_panel = self.add_client_tab("Hints", HintLayout()) self.hint_log = HintLog(self.json_to_kivy_parser) + hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log)) self.log_panels["Hints"] = hint_panel.content - hint_panel.content.add_widget(self.hint_log) self.main_area_container = MDGridLayout(size_hint_y=1, rows=1) - self.main_area_container.add_widget(self.tabs) + tab_container = MDGridLayout(size_hint_y=1, cols=1) + tab_container.add_widget(self.tabs) + tab_container.add_widget(self.screens) + self.main_area_container.add_widget(tab_container) self.grid.add_widget(self.main_area_container) @@ -974,25 +962,61 @@ class GameManager(ThemedApp): return self.container - def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget: - """Adds a new tab to the client window with a given title, and provides a given Widget as its content. - Returns the new tab widget, with the provided content being placed on the tab as content.""" - new_tab = MDTabsItem(MDTabsItemText(text=title)) + def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase: + """ + Adds a new tab to the client window with a given title, and provides a given Widget as its content. + Returns the new tab widget, with the provided content being placed on the tab as content. + + :param title: The title of the tab. + :param content: The Widget to be added as content for this tab's new MDScreen. Will also be added to the + returned tab as tab.content. + :param index: The index to insert the tab at. Defaults to -1, meaning the tab will be appended to the end. + + :return: The new tab. + """ + if self.tabs.children: + self.tabs.add_widget(MDDivider(orientation="vertical")) + new_tab = MDNavigationItemBase(text=title) new_tab.content = content - if -1 < index <= len(self.tabs.carousel.slides): - new_tab.bind(on_release=self.tabs.set_active_item) - new_tab._tabs = self.tabs - self.tabs.ids.container.add_widget(new_tab, index=index) - self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index) + new_screen = MDScreen(name=title) + new_screen.add_widget(content) + if -1 < index <= len(self.tabs.children): + remapped_index = len(self.tabs.children) - index + self.tabs.add_widget(new_tab, index=remapped_index) + self.screens.add_widget(new_screen, index=index) else: self.tabs.add_widget(new_tab) - self.tabs.carousel.add_widget(new_tab.content) + self.screens.add_widget(new_screen) return new_tab + def remove_client_tab(self, tab: MDNavigationItemBase) -> None: + """ + Called to remove a tab and its screen. + + :param tab: The tab to remove. + """ + tab_index = self.tabs.children.index(tab) + # if the tab is currently active we need to swap before removing it + if tab == self.screens.current_tab: + if not tab_index: + # account for the divider + swap_index = tab_index + 2 + else: + swap_index = tab_index - 2 + self.tabs.children[swap_index].on_release() + # self.screens.switch_screens(self.tabs.children[swap_index]) + # get the divider to the left if we can + if not tab_index: + divider_index = tab_index + 1 + else: + divider_index = tab_index - 1 + self.tabs.remove_widget(self.tabs.children[divider_index]) + self.tabs.remove_widget(tab) + self.screens.remove_widget(self.screens.get_screen(tab.text)) + def update_texts(self, dt): - for slide in self.tabs.carousel.slides: - if hasattr(slide, "fix_heights"): - slide.fix_heights() # TODO: remove this when Kivy fixes this upstream + if hasattr(self.screens.current_tab.content, "fix_heights"): + getattr(self.screens.current_tab.content, "fix_heights")() if self.ctx.server: self.title = self.base_title + " " + Utils.__version__ + \ f" | Connected to: {self.ctx.server_address} " \ diff --git a/pytest.ini b/pytest.ini index 4469a7c30d..f050d58b70 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported +python_files = test_*.py Test*.py **/test*/**/__init__.py # TODO: remove Test* once all worlds have been ported python_classes = Test python_functions = test testpaths = diff --git a/setup.py b/setup.py index 87e08c2dca..6c72d575f6 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,7 @@ import subprocess import sys import sysconfig import threading +import urllib.error import urllib.request import warnings import zipfile @@ -16,6 +17,10 @@ from collections.abc import Iterable, Sequence from hashlib import sha3_512 from pathlib import Path + +SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fixed + + # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it requirement = 'cx-Freeze==8.2.0' try: @@ -77,7 +82,8 @@ def download_SNI() -> None: machine_name = platform.machine().lower() # force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name) - with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request: + sni_version_ref = "latest" if SNI_VERSION == "latest" else f"tags/{SNI_VERSION}" + with urllib.request.urlopen(f"https://api.github.com/repos/alttpo/SNI/releases/{sni_version_ref}") as request: data = json.load(request) files = data["assets"] @@ -91,8 +97,8 @@ def download_SNI() -> None: # prefer "many" builds if "many" in download_url: break - # prefer the correct windows or windows7 build - if platform_name == "windows" and ("windows7" in download_url) == (sys.version_info < (3, 9)): + # prefer non-windows7 builds to get up-to-date dependencies + if platform_name == "windows" and "windows7" not in download_url: break if source_url and source_url.endswith(".zip"): @@ -131,15 +137,16 @@ def download_SNI() -> None: print(f"No SNI found for system spec {platform_name} {machine_name}") -signtool: str | None -if os.path.exists("X:/pw.txt"): - print("Using signtool") - with open("X:/pw.txt", encoding="utf-8-sig") as f: - pw = f.read() - signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \ - r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ ' -else: - signtool = None +signtool: str | None = None +try: + with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response: + html = response.read() + if b"status=OK\n" in html: + signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 ' + r'/tr http://timestamp.digicert.com/ ') + print("Using signtool") +except (ConnectionError, TimeoutError, urllib.error.URLError) as e: + pass build_platform = sysconfig.get_platform() @@ -184,9 +191,10 @@ extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else [] def remove_sprites_from_folder(folder: Path) -> None: - for file in os.listdir(folder): - if file != ".gitignore": - os.remove(folder / file) + if os.path.isdir(folder): + for file in os.listdir(folder): + if file != ".gitignore": + os.remove(folder / file) def _threaded_hash(filepath: str | Path) -> str: @@ -395,13 +403,14 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe): os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path)) remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr") + remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttp" / "remote") self.create_manifest() if is_windows: # Inno setup stuff with open("setup.ini", "w") as f: - min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000" + min_supported_windows = "6.2.9200" f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n") with open("installdelete.iss", "w") as f: f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n" @@ -468,7 +477,7 @@ tmp="${{exe#*/}}" if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then exe="{default_exe.parent}/$exe" fi -export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib" +export LD_LIBRARY_PATH="${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}$APPDIR/{default_exe.parent}/lib" $APPDIR/$exe "$@" """) launcher_filename.chmod(0o755) diff --git a/test/benchmark/locations.py b/test/benchmark/locations.py index 16667a17b9..0e496cd3ee 100644 --- a/test/benchmark/locations.py +++ b/test/benchmark/locations.py @@ -29,14 +29,9 @@ def run_locations_benchmark(): rule_iterations: int = 100_000 - if sys.version_info >= (3, 9): - @staticmethod - def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: - return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) - else: - @staticmethod - def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str: - return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) + @staticmethod + def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: + return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: with TimeIt(f"{test_location.game} {self.rule_iterations} " diff --git a/test/benchmark/match.py b/test/benchmark/match.py new file mode 100644 index 0000000000..ccb600c0ba --- /dev/null +++ b/test/benchmark/match.py @@ -0,0 +1,66 @@ +"""Micro benchmark comparing match as "switch" with if-elif and dict access""" + +from timeit import timeit + + +def make_match(count: int) -> str: + code = f"for val in range({count}):\n match val:\n" + for n in range(count): + m = n + 1 + code += f" case {n}:\n" + code += f" res = {m}\n" + return code + + +def make_elif(count: int) -> str: + code = f"for val in range({count}):\n" + for n in range(count): + m = n + 1 + code += f" {'' if n == 0 else 'el'}if val == {n}:\n" + code += f" res = {m}\n" + return code + + +def make_dict(count: int, mode: str) -> str: + if mode == "value": + code = "dct = {\n" + for n in range(count): + m = n + 1 + code += f" {n}: {m},\n" + code += "}\n" + code += f"for val in range({count}):\n res = dct[val]" + return code + elif mode == "call": + code = "" + for n in range(count): + m = n + 1 + code += f"def func{n}():\n val = {m}\n\n" + code += "dct = {\n" + for n in range(count): + code += f" {n}: func{n},\n" + code += "}\n" + code += f"for val in range({count}):\n dct[val]()" + return code + return "" + + +def timeit_best_of_5(stmt: str, setup: str = "pass") -> float: + """ + Benchmark some code, returning the best of 5 runs. + :param stmt: Code to benchmark + :param setup: Optional code to set up environment + :return: Time taken in microseconds + """ + return min(timeit(stmt, setup, number=10000, globals={}) for _ in range(5)) * 100 + + +def main() -> None: + for count in (3, 5, 8, 10, 20, 30): + print(f"value of {count:-2} with match: {timeit_best_of_5(make_match(count)) / count:.3f} us") + print(f"value of {count:-2} with elif: {timeit_best_of_5(make_elif(count)) / count:.3f} us") + print(f"value of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'value')) / count:.3f} us") + print(f"call of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'call')) / count:.3f} us") + + +if __name__ == "__main__": + main() diff --git a/test/general/test_entrance_rando.py b/test/general/test_entrance_rando.py index 65853dfc8b..8a697030e8 100644 --- a/test/general/test_entrance_rando.py +++ b/test/general/test_entrance_rando.py @@ -69,11 +69,9 @@ class TestEntranceLookup(unittest.TestCase): exits_set = set([ex for region in multiworld.get_regions(1) for ex in region.exits if not ex.connected_region]) - lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set) er_targets = [entrance for region in multiworld.get_regions(1) for entrance in region.entrances if not entrance.parent_region] - for entrance in er_targets: - lookup.add(entrance) + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], False, False) @@ -92,11 +90,9 @@ class TestEntranceLookup(unittest.TestCase): exits_set = set([ex for region in multiworld.get_regions(1) for ex in region.exits if not ex.connected_region]) - lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set) er_targets = [entrance for region in multiworld.get_regions(1) for entrance in region.entrances if not entrance.parent_region] - for entrance in er_targets: - lookup.add(entrance) + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], False, True) @@ -112,12 +108,10 @@ class TestEntranceLookup(unittest.TestCase): for ex in region.exits if not ex.connected_region and ex.name != "region20_right" and ex.name != "region21_left"]) - lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set) er_targets = [entrance for region in multiworld.get_regions(1) for entrance in region.entrances if not entrance.parent_region and entrance.name != "region20_right" and entrance.name != "region21_left"] - for entrance in er_targets: - lookup.add(entrance) + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) # region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21 # and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21, # the top entrance from region 15 should be considered a dead-end @@ -129,6 +123,56 @@ class TestEntranceLookup(unittest.TestCase): self.assertTrue(dead_end in lookup.dead_ends) self.assertEqual(len(lookup.dead_ends), 1) + def test_find_target_by_name(self): + """Tests that find_target can find the correct target by name only""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + exits_set = set([ex for region in multiworld.get_regions(1) + for ex in region.exits if not ex.connected_region]) + + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) + + target = lookup.find_target("region0_right") + self.assertEqual(target.name, "region0_right") + self.assertEqual(target.randomization_group, ERTestGroups.RIGHT) + self.assertIsNone(lookup.find_target("nonexistant")) + + def test_find_target_by_name_and_group(self): + """Tests that find_target can find the correct target by name and group""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + exits_set = set([ex for region in multiworld.get_regions(1) + for ex in region.exits if not ex.connected_region]) + + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) + + target = lookup.find_target("region0_right", ERTestGroups.RIGHT) + self.assertEqual(target.name, "region0_right") + self.assertEqual(target.randomization_group, ERTestGroups.RIGHT) + # wrong group + self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.LEFT)) + + def test_find_target_by_name_and_group_and_category(self): + """Tests that find_target can find the correct target by name, group, and dead-endedness""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + exits_set = set([ex for region in multiworld.get_regions(1) + for ex in region.exits if not ex.connected_region]) + + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets) + + target = lookup.find_target("region0_right", ERTestGroups.RIGHT, False) + self.assertEqual(target.name, "region0_right") + self.assertEqual(target.randomization_group, ERTestGroups.RIGHT) + # wrong deadendedness + self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.RIGHT, True)) + class TestBakeTargetGroupLookup(unittest.TestCase): def test_lookup_generation(self): multiworld = generate_test_multiworld() @@ -265,12 +309,12 @@ class TestRandomizeEntrances(unittest.TestCase): generate_disconnected_region_grid(multiworld, 5) seen_placement_count = 0 - def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]): + def verify_coupled(_: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]): nonlocal seen_placement_count - seen_placement_count += len(placed_entrances) - self.assertEqual(2, len(placed_entrances)) - self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region) - self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region) + seen_placement_count += len(placed_exits) + self.assertEqual(2, len(placed_exits)) + self.assertEqual(placed_exits[0].parent_region, placed_exits[1].connected_region) + self.assertEqual(placed_exits[1].parent_region, placed_exits[0].connected_region) result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup, on_connect=verify_coupled) @@ -313,10 +357,10 @@ class TestRandomizeEntrances(unittest.TestCase): generate_disconnected_region_grid(multiworld, 5) seen_placement_count = 0 - def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]): + def verify_uncoupled(state: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]): nonlocal seen_placement_count - seen_placement_count += len(placed_entrances) - self.assertEqual(1, len(placed_entrances)) + seen_placement_count += len(placed_exits) + self.assertEqual(1, len(placed_exits)) result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup, on_connect=verify_uncoupled) diff --git a/test/general/test_fill.py b/test/general/test_fill.py index c8bcec9581..bdc38d7913 100644 --- a/test/general/test_fill.py +++ b/test/general/test_fill.py @@ -603,6 +603,28 @@ class TestDistributeItemsRestrictive(unittest.TestCase): self.assertTrue(player3.locations[2].item.advancement) self.assertTrue(player3.locations[3].item.advancement) + def test_deprioritized_does_not_land_on_priority(self): + multiworld = generate_test_multiworld(1) + player1 = generate_player_data(multiworld, 1, 2, prog_item_count=2) + + player1.prog_items[0].classification |= ItemClassification.deprioritized + player1.locations[0].progress_type = LocationProgressType.PRIORITY + + distribute_items_restrictive(multiworld) + + self.assertFalse(player1.locations[0].item.deprioritized) + + def test_deprioritized_still_goes_on_priority_ahead_of_filler(self): + multiworld = generate_test_multiworld(1) + player1 = generate_player_data(multiworld, 1, 2, prog_item_count=1, basic_item_count=1) + + player1.prog_items[0].classification |= ItemClassification.deprioritized + player1.locations[0].progress_type = LocationProgressType.PRIORITY + + distribute_items_restrictive(multiworld) + + self.assertTrue(player1.locations[0].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""" multiworld = generate_test_multiworld() diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index b74f82b738..cf0624a288 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -1,7 +1,7 @@ import unittest from Fill import distribute_items_restrictive -from NetUtils import encode +from NetUtils import convert_to_base_types from worlds.AutoWorld import AutoWorldRegister, call_all from worlds import failed_world_loads from . import setup_solo_multiworld @@ -47,7 +47,7 @@ class TestImplemented(unittest.TestCase): 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.") + convert_to_base_types(data) # only put base data types into slot data def test_no_failed_world_loads(self): if failed_world_loads: diff --git a/test/general/test_items.py b/test/general/test_items.py index 1b376b2838..dbaca1c91c 100644 --- a/test/general/test_items.py +++ b/test/general/test_items.py @@ -148,8 +148,8 @@ class TestBase(unittest.TestCase): def test_locality_not_modified(self): """Test that worlds don't modify the locality of items after duplicates are resolved""" - gen_steps = ("generate_early", "create_regions", "create_items") - additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill") + gen_steps = ("generate_early",) + additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill") worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()} for game_name, world_type in worlds_to_test.items(): with self.subTest("Game", game=game_name): diff --git a/test/general/test_options.py b/test/general/test_options.py index 7a3743e5a4..d8ce7017f2 100644 --- a/test/general/test_options.py +++ b/test/general/test_options.py @@ -1,7 +1,8 @@ import unittest -from BaseClasses import MultiWorld, PlandoOptions -from Options import ItemLinks +from BaseClasses import PlandoOptions +from Options import ItemLinks, Choice +from Utils import restricted_dumps from worlds.AutoWorld import AutoWorldRegister @@ -73,9 +74,10 @@ class TestOptions(unittest.TestCase): def test_pickle_dumps(self): """Test options can be pickled into database for WebHost generation""" - import pickle for gamename, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: for option_key, option in world_type.options_dataclass.type_hints.items(): with self.subTest(game=gamename, option=option_key): - pickle.dumps(option.from_any(option.default)) + restricted_dumps(option.from_any(option.default)) + if issubclass(option, Choice) and option.default in option.name_lookup: + restricted_dumps(option.from_text(option.name_lookup[option.default])) diff --git a/test/general/test_packages.py b/test/general/test_packages.py index 32c7bdf47e..1df6187ee0 100644 --- a/test/general/test_packages.py +++ b/test/general/test_packages.py @@ -8,7 +8,12 @@ class TestPackages(unittest.TestCase): to indicate full package rather than namespace package.""" import Utils + # Ignore directories with these names. + ignore_dirs = {".github"} + worlds_path = Utils.local_path("worlds") for dirpath, dirnames, filenames in os.walk(worlds_path): + # Drop ignored directories from dirnames, excluding them from walking. + dirnames[:] = [d for d in dirnames if d not in ignore_dirs] with self.subTest(directory=dirpath): self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames)) diff --git a/test/hosting/__main__.py b/test/hosting/__main__.py index 6640c637b5..e235d7bb72 100644 --- a/test/hosting/__main__.py +++ b/test/hosting/__main__.py @@ -63,12 +63,12 @@ if __name__ == "__main__": spacer = '=' * 80 with TemporaryDirectory() as tempdir: - multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]] + multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] p1_games = [] data_paths = [] rooms = [] - copy_world("Clique", "Temp World") + copy_world("VVVVVV", "Temp World") try: for n, games in enumerate(multis, 1): print(f"Generating [{n}] {', '.join(games)}") @@ -101,7 +101,7 @@ if __name__ == "__main__": with Client(host.address, game, "Player1") as client: local_data_packages = client.games_packages local_collected_items = len(client.checked_locations) - if collected_items < 2: # Clique only has 2 Locations + if collected_items < 2: # Don't collect anything on the last iteration client.collect_any() # TODO: Ctrl+C test here as well @@ -125,7 +125,7 @@ if __name__ == "__main__": with Client(host.address, game, "Player1") as client: web_data_packages = client.games_packages web_collected_items = len(client.checked_locations) - if collected_items < 2: # Clique only has 2 Locations + if collected_items < 2: # Don't collect anything on the last iteration client.collect_any() if collected_items == 1: sleep(1) # wait for the server to collect the item diff --git a/test/hosting/generate.py b/test/hosting/generate.py index d5d39dc95e..e90868eb6f 100644 --- a/test/hosting/generate.py +++ b/test/hosting/generate.py @@ -34,7 +34,7 @@ def _generate_local_inner(games: Iterable[str], f.write(json.dumps({ "name": f"Player{n}", "game": game, - game: {"hard_mode": "true"}, + game: {}, "description": f"generate_local slot {n} ('Player{n}'): {game}", })) diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py index 4db605e8c1..8888c3fb87 100644 --- a/test/hosting/webhost.py +++ b/test/hosting/webhost.py @@ -2,6 +2,8 @@ import re from pathlib import Path from typing import TYPE_CHECKING, Optional, cast +from WebHostLib import to_python + if TYPE_CHECKING: from flask import Flask from werkzeug.test import Client as FlaskClient @@ -103,7 +105,7 @@ def stop_room(app_client: "FlaskClient", poll_interval = 2 print(f"Stopping room {room_id}") - room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + room_uuid = to_python(room_id) if timeout is not None: sleep(.1) # should not be required, but other things might use threading @@ -156,7 +158,7 @@ def set_room_timeout(room_id: str, timeout: float) -> None: from WebHostLib.models import Room from WebHostLib import app - room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + room_uuid = to_python(room_id) with db_session: room: Room = Room.get(id=room_uuid) room.timeout = timeout @@ -168,7 +170,7 @@ def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes from WebHostLib.models import Room from WebHostLib import app - room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + room_uuid = to_python(room_id) with db_session: room: Room = Room.get(id=room_uuid) return cast(bytes, room.seed.multidata) @@ -180,7 +182,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by from WebHostLib.models import Room from WebHostLib import app - room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + room_uuid = to_python(room_id) with db_session: room: Room = Room.get(id=room_uuid) room.seed.multidata = data diff --git a/test/hosting/world.py b/test/hosting/world.py index 7412641201..cd53453c10 100644 --- a/test/hosting/world.py +++ b/test/hosting/world.py @@ -30,7 +30,7 @@ def copy(src: str, dst: str) -> None: _new_worlds[dst] = str(dst_folder) with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f: contents = f.read() - contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents) + contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents) with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f: f.write(contents) diff --git a/test/options/test_option_classes.py b/test/options/test_option_classes.py index 8e2c4702c3..ca90db8870 100644 --- a/test/options/test_option_classes.py +++ b/test/options/test_option_classes.py @@ -33,6 +33,15 @@ class TestNumericOptions(unittest.TestCase): self.assertEqual(choice_option_alias, TestChoice.alias_three) self.assertEqual(choice_option_attr, TestChoice.non_option_attr) + self.assertLess(choice_option_string, "two") + self.assertGreater(choice_option_string, "zero") + self.assertLessEqual(choice_option_string, "one") + self.assertLessEqual(choice_option_string, "two") + self.assertGreaterEqual(choice_option_string, "one") + self.assertGreaterEqual(choice_option_string, "zero") + + self.assertGreaterEqual(choice_option_alias, "three") + self.assertRaises(KeyError, TestChoice.from_any, "four") self.assertIn(choice_option_int, [1, 2, 3]) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 6ea6c237d9..9233f3d217 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -16,7 +16,7 @@ from Utils import deprecate if TYPE_CHECKING: from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance - from . import GamesPackage + from NetUtils import GamesPackage, MultiData from settings import Group perf_logger = logging.getLogger("performance") @@ -72,15 +72,6 @@ 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 - deprecate(f"{name} Assigned options through option_definitions which is now deprecated. " - "Please 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) new_class.__file__ = sys.modules[new_class.__module__].__file__ @@ -382,7 +373,7 @@ class World(metaclass=AutoWorldRegister): def create_items(self) -> None: """ Method for creating and submitting items to the itempool. Items and Regions must *not* be created and submitted - to the MultiWorld after this step. If items need to be placed during pre_fill use `get_prefill_items`. + to the MultiWorld after this step. If items need to be placed during pre_fill use `get_pre_fill_items`. """ pass @@ -450,7 +441,7 @@ class World(metaclass=AutoWorldRegister): """ pass - def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata? + def modify_multidata(self, multidata: "MultiData") -> None: """For deeper modification of server multidata.""" pass @@ -493,9 +484,6 @@ class World(metaclass=AutoWorldRegister): 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.from_any(option.default) group = cls(multiworld, new_player_id) group.options = cls.options_dataclass(**{option_key: option.from_any(option.default) for option_key, option in cls.options_dataclass.type_hints.items()}) diff --git a/worlds/Files.py b/worlds/Files.py index 447219bd19..27c0e9c42e 100644 --- a/worlds/Files.py +++ b/worlds/Files.py @@ -15,7 +15,6 @@ import bsdiff4 semaphore = threading.Semaphore(os.cpu_count() or 4) del threading -del os class AutoPatchRegister(abc.ABCMeta): @@ -34,10 +33,8 @@ class AutoPatchRegister(abc.ABCMeta): @staticmethod def get_handler(file: str) -> Optional[AutoPatchRegister]: - for file_ending, handler in AutoPatchRegister.file_endings.items(): - if file.endswith(file_ending): - return handler - return None + _, suffix = os.path.splitext(file) + return AutoPatchRegister.file_endings.get(suffix, None) class AutoPatchExtensionRegister(abc.ABCMeta): @@ -158,6 +155,7 @@ class APContainer: class APPlayerContainer(APContainer): """A zipfile containing at least archipelago.json meant for a player""" game: ClassVar[Optional[str]] = None + patch_file_ending: str = "" player: Optional[int] player_name: str @@ -184,6 +182,7 @@ class APPlayerContainer(APContainer): "player": self.player, "player_name": self.player_name, "game": self.game, + "patch_file_ending": self.patch_file_ending, }) return manifest @@ -223,7 +222,6 @@ class APProcedurePatch(APAutoPatchInterface): """ hash: Optional[str] # base checksum of source file source_data: bytes - patch_file_ending: str = "" files: Dict[str, bytes] @classmethod @@ -245,7 +243,6 @@ class APProcedurePatch(APAutoPatchInterface): manifest = super(APProcedurePatch, self).get_manifest() manifest["base_checksum"] = self.hash manifest["result_file_ending"] = self.result_file_ending - manifest["patch_file_ending"] = self.patch_file_ending manifest["procedure"] = self.procedure if self.procedure == APDeltaPatch.procedure: manifest["compatible_version"] = 5 diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 2bd9636931..06c77ab060 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -210,16 +210,17 @@ components: List[Component] = [ Component('Launcher', 'Launcher', component_type=Type.HIDDEN), # Core Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True, - file_identifier=SuffixIdentifier('.archipelago', '.zip')), - Component('Generate', 'Generate', cli=True), - Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")), - Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient), + file_identifier=SuffixIdentifier('.archipelago', '.zip'), + description="Host a generated multiworld on your computer."), + Component('Generate', 'Generate', cli=True, + description="Generate a multiworld with the YAMLs in the players folder."), + Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"), + description="Install an APWorld to play games not included with Archipelago by default."), + Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient, + description="Connect to a multiworld using the text client."), Component('Links Awakening DX Client', 'LinksAwakeningClient', file_identifier=SuffixIdentifier('.apladx')), Component('LttP Adjuster', 'LttPAdjuster'), - # Minecraft - Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True, - file_identifier=SuffixIdentifier('.apmc')), # Ocarina of Time Component('OoT Client', 'OoTClient', file_identifier=SuffixIdentifier('.apz5')), @@ -242,6 +243,5 @@ components: List[Component] = [ # if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used icon_paths = { 'icon': local_path('data', 'icon.png'), - 'mcicon': local_path('data', 'mcicon.png'), 'discord': local_path('data', 'discord-mark-blue.png'), } diff --git a/worlds/__init__.py b/worlds/__init__.py index 7db651bdd9..89f7bcd063 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -7,8 +7,9 @@ import warnings import zipimport import time import dataclasses -from typing import Dict, List, TypedDict +from typing import List +from NetUtils import DataPackage from Utils import local_path, user_path local_folder = os.path.dirname(__file__) @@ -24,8 +25,6 @@ __all__ = { "world_sources", "local_folder", "user_folder", - "GamesPackage", - "DataPackage", "failed_world_loads", } @@ -33,18 +32,6 @@ __all__ = { failed_world_loads: List[str] = [] -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 - - -class DataPackage(TypedDict): - games: Dict[str, GamesPackage] - - @dataclasses.dataclass(order=True) class WorldSource: path: str # typically relative path from this module @@ -76,9 +63,7 @@ class WorldSource: sys.modules[mod.__name__] = mod with warnings.catch_warnings(): warnings.filterwarnings("ignore", message="__package__ != __spec__.parent") - # Found no equivalent for < 3.10 - if hasattr(importer, "exec_module"): - importer.exec_module(mod) + importer.exec_module(mod) else: importlib.import_module(f".{self.path}", "worlds") self.time_taken = time.perf_counter()-start diff --git a/worlds/_bizhawk/client.py b/worlds/_bizhawk/client.py index 16a8325a10..fe8e97e65c 100644 --- a/worlds/_bizhawk/client.py +++ b/worlds/_bizhawk/client.py @@ -19,7 +19,8 @@ def launch_client(*args) -> None: component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, - file_identifier=SuffixIdentifier()) + file_identifier=SuffixIdentifier(), + description="Open the BizHawk client, to play games using the Bizhawk emulator.") components.append(component) diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index c9b1076644..142c296400 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -4,16 +4,18 @@ checking or launching the client, otherwise it will probably cause circular impo """ import asyncio +import copy import enum import subprocess from typing import Any +import settings 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 + get_script_version, get_system, ping, display_message from .client import BizHawkClient, AutoBizHawkClientRegister @@ -27,20 +29,97 @@ class AuthStatus(enum.IntEnum): AUTHENTICATED = 3 +class TextCategory(str, enum.Enum): + ALL = "all" + INCOMING = "incoming" + OUTGOING = "outgoing" + OTHER = "other" + HINT = "hint" + CHAT = "chat" + SERVER = "server" + + 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") + assert 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") + + def _cmd_toggle_text(self, category: str | None = None, toggle: str | None = None): + """Sets types of incoming messages to forward to the emulator""" + assert isinstance(self.ctx, BizHawkClientContext) + + if category is None: + logger.info("Usage: /toggle_text category [toggle]\n\n" + "category: incoming, outgoing, other, hint, chat, and server\n" + "Or \"all\" to toggle all categories at once\n\n" + "toggle: on, off, true, or false\n" + "Or omit to set it to the opposite of its current state\n\n" + "Example: /toggle_text outgoing on") + return + + category = category.lower() + value: bool | None + if toggle is None: + value = None + elif toggle.lower() in ("on", "true"): + value = True + elif toggle.lower() in ("off", "false"): + value = False + else: + logger.info(f'Unknown value "{toggle}", should be on|off|true|false') + return + + valid_categories = ( + TextCategory.ALL, + TextCategory.OTHER, + TextCategory.INCOMING, + TextCategory.OUTGOING, + TextCategory.HINT, + TextCategory.CHAT, + TextCategory.SERVER, + ) + if category not in valid_categories: + logger.info(f'Unknown value "{category}", should be {"|".join(valid_categories)}') + return + + if category == TextCategory.ALL: + if value is None: + logger.info('Must specify "on" or "off" for category "all"') + return + + if value: + self.ctx.text_passthrough_categories.update(( + TextCategory.OTHER, + TextCategory.INCOMING, + TextCategory.OUTGOING, + TextCategory.HINT, + TextCategory.CHAT, + TextCategory.SERVER, + )) + else: + self.ctx.text_passthrough_categories.clear() + else: + if value is None: + value = category not in self.ctx.text_passthrough_categories + + if value: + self.ctx.text_passthrough_categories.add(category) + else: + self.ctx.text_passthrough_categories.remove(category) + + logger.info(f"Currently Showing Categories: {', '.join(self.ctx.text_passthrough_categories)}") class BizHawkClientContext(CommonContext): command_processor = BizHawkClientCommandProcessor + text_passthrough_categories: set[str] server_seed_name: str | None = None auth_status: AuthStatus password_requested: bool @@ -54,12 +133,33 @@ class BizHawkClientContext(CommonContext): def __init__(self, server_address: str | None, password: str | None): super().__init__(server_address, password) + self.text_passthrough_categories = set() self.auth_status = AuthStatus.NOT_AUTHENTICATED self.password_requested = False self.client_handler = None self.bizhawk_ctx = BizHawkContext() self.watcher_timeout = 0.5 + def _categorize_text(self, args: dict) -> TextCategory: + if "type" not in args or args["type"] in {"Hint", "Join", "Part", "TagsChanged", "Goal", "Release", "Collect", + "Countdown", "ServerChat", "ItemCheat"}: + return TextCategory.SERVER + elif args["type"] == "Chat": + return TextCategory.CHAT + elif args["type"] == "ItemSend": + if args["item"].player == self.slot: + return TextCategory.OUTGOING + elif args["receiving"] == self.slot: + return TextCategory.INCOMING + else: + return TextCategory.OTHER + + def on_print_json(self, args: dict): + super().on_print_json(args) + if self.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED: + if self._categorize_text(args) in self.text_passthrough_categories: + Utils.async_start(display_message(self.bizhawk_ctx, self.rawjsontotextparser(copy.deepcopy(args["data"])))) + def make_gui(self): ui = super().make_gui() ui.base_title = "Archipelago BizHawk Client" @@ -205,10 +305,10 @@ async def _game_watcher(ctx: BizHawkClientContext): async def _run_game(rom: str): import os - auto_start = Utils.get_settings().bizhawkclient_options.rom_start + auto_start = settings.get_settings().bizhawkclient_options.rom_start if auto_start is True: - emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path + emuhawk_path = settings.get_settings().bizhawkclient_options.emuhawk_path subprocess.Popen( [ emuhawk_path, diff --git a/worlds/ahit/__init__.py b/worlds/ahit/__init__.py index 1bcc840ae6..d258f8050d 100644 --- a/worlds/ahit/__init__.py +++ b/worlds/ahit/__init__.py @@ -260,11 +260,7 @@ class HatInTimeWorld(World): f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})") slot_data["ShopItemNames"] = shop_item_names - - for name, value in self.options.as_dict(*self.options_dataclass.type_hints).items(): - if name in slot_data_options: - slot_data[name] = value - + slot_data.update(self.options.as_dict(*slot_data_options)) return slot_data def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]): diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index 39e8d7072b..6b7da69593 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -209,8 +209,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld): if localized: in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized] if in_dungeon_items: - restricted_players = {player for player, restricted in multiworld.restrict_dungeon_item_on_boss.items() if - restricted} + restricted_players = {world.player for world in multiworld.get_game_worlds("A Link to the Past") if + world.options.restrict_dungeon_item_on_boss} locations: typing.List["ALttPLocation"] = [ location for location in get_unfilled_dungeon_locations(multiworld) # filter boss @@ -255,8 +255,9 @@ def fill_dungeons_restrictive(multiworld: MultiWorld): if all_state_base.has("Triforce", player): all_state_base.remove(multiworld.worlds[player].create_item("Triforce")) - for (player, key_drop_shuffle) in multiworld.key_drop_shuffle.items(): - if not key_drop_shuffle and player not in multiworld.groups: + for lttp_world in multiworld.get_game_worlds("A Link to the Past"): + if not lttp_world.options.key_drop_shuffle and lttp_world.player not in multiworld.groups: + player = lttp_world.player for key_loc in key_drop_data: key_data = key_drop_data[key_loc] all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player])) diff --git a/worlds/alttp/ItemPool.py b/worlds/alttp/ItemPool.py index 57ad01b9e4..53059c64bc 100644 --- a/worlds/alttp/ItemPool.py +++ b/worlds/alttp/ItemPool.py @@ -223,7 +223,7 @@ items_reduction_table = ( def generate_itempool(world): - player = world.player + player: int = world.player multiworld = world.multiworld if world.options.item_pool.current_key not in difficulties: @@ -280,7 +280,6 @@ def generate_itempool(world): if multiworld.custom: pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = ( make_custom_item_pool(multiworld, player)) - multiworld.rupoor_cost = min(multiworld.customitemarray[67], 9999) else: (pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total, additional_triforce_pieces) = get_pool_core(multiworld, player) @@ -386,8 +385,8 @@ def generate_itempool(world): if world.options.retro_bow: shop_items = 0 - shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if - shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if + shop_locations = [location for shop_locations in (shop.region.locations for shop in world.shops if + shop.type == ShopType.Shop) for location in shop_locations if location.shop_slot is not None] for location in shop_locations: if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow": @@ -546,12 +545,14 @@ def set_up_take_anys(multiworld, world, player): connect_entrance(multiworld, entrance.name, old_man_take_any.name, player) entrance.target = 0x58 old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots) - multiworld.shops.append(old_man_take_any.shop) + world.shops.append(old_man_take_any.shop) - swords = [item for item in multiworld.itempool if item.player == player and item.type == 'Sword'] - if swords: - sword = multiworld.random.choice(swords) - multiworld.itempool.remove(sword) + sword_indices = [ + index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword' + ] + if sword_indices: + sword_index = multiworld.random.choice(sword_indices) + sword = multiworld.itempool.pop(sword_index) multiworld.itempool.append(item_factory('Rupees (20)', world)) old_man_take_any.shop.add_inventory(0, sword.name, 0, 0) loc_name = "Old Man Sword Cave" @@ -572,7 +573,7 @@ def set_up_take_anys(multiworld, world, player): connect_entrance(multiworld, entrance.name, take_any.name, player) entrance.target = target take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1) - multiworld.shops.append(take_any.shop) + world.shops.append(take_any.shop) take_any.shop.add_inventory(0, 'Blue Potion', 0, 0) take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0) location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any) diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index 99cc78e2d9..6a5792d21a 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -1,6 +1,7 @@ from __future__ import annotations import Utils +import settings import worlds.Files LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173" @@ -514,7 +515,8 @@ def _populate_sprite_table(): logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.") with concurrent.futures.ThreadPoolExecutor() as pool: - sprite_paths = [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')] + sprite_paths = [user_path("data", "sprites", "alttp", "remote"), + user_path("data", "sprites", "alttp", "custom")] for dir in [dir for dir in sprite_paths if os.path.isdir(dir)]: for file in os.listdir(dir): pool.submit(load_sprite_from_file, os.path.join(dir, file)) @@ -1001,14 +1003,19 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): # set light cones rom.write_byte(0x180038, 0x01 if local_world.options.mode == "standard" else 0x00) - rom.write_byte(0x180039, 0x01 if world.light_world_light_cone else 0x00) - rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00) + # light world light cone + rom.write_byte(0x180039, local_world.light_world_light_cone) + # dark world light cone + rom.write_byte(0x18003A, local_world.dark_world_light_cone) GREEN_TWENTY_RUPEES = 0x47 GREEN_CLOCK = item_table["Green Clock"].item_code rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on + # Rupoor negative value + rom.write_int16(0x180036, local_world.rupoor_cost) + # handle item_functionality if local_world.options.item_functionality == 'hard': rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon @@ -1026,8 +1033,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): # Disable catching fairies rom.write_byte(0x34FD6, 0x80) overflow_replacement = GREEN_TWENTY_RUPEES - # Rupoor negative value - rom.write_int16(0x180036, world.rupoor_cost) # Set stun items rom.write_byte(0x180180, 0x02) # Hookshot only elif local_world.options.item_functionality == 'expert': @@ -1046,8 +1051,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): # Disable catching fairies rom.write_byte(0x34FD6, 0x80) overflow_replacement = GREEN_TWENTY_RUPEES - # Rupoor negative value - rom.write_int16(0x180036, world.rupoor_cost) # Set stun items rom.write_byte(0x180180, 0x00) # Nothing else: @@ -1065,8 +1068,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): rom.write_byte(0x18004F, 0x01) # Enable catching fairies rom.write_byte(0x34FD6, 0xF0) - # Rupoor negative value - rom.write_int16(0x180036, world.rupoor_cost) # Set stun items rom.write_byte(0x180180, 0x03) # All standard items # Set overflow items for progressive equipment @@ -1312,7 +1313,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): rom.write_byte(0x18008C, 0x01 if local_world.options.crystals_needed_for_gt == 0 else 0x00) # GT pre-opened if crystal requirement is 0 rom.write_byte(0xF5D73, 0xF0) # bees are catchable rom.write_byte(0xF5F10, 0xF0) # bees are catchable - rom.write_byte(0x180086, 0x00 if world.aga_randomness else 0x01) # set blue ball and ganon warp randomness + rom.write_byte(0x180086, 0x00) # set blue ball and ganon warp randomness rom.write_byte(0x1800A0, 0x01) # return to light world on s+q without mirror rom.write_byte(0x1800A1, 0x01) # enable overworld screen transition draining for water level inside swamp rom.write_byte(0x180174, 0x01 if local_world.fix_fake_world else 0x00) @@ -1617,7 +1618,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills rom.write_byte(0x1800A4, 0x01 if local_world.options.glitches_required != 'no_logic' else 0x00) # enable POD EG fix rom.write_byte(0x186383, 0x01 if local_world.options.glitches_required == 'no_logic' else 0x00) # disable glitching to Triforce from Ganons Room - rom.write_byte(0x180042, 0x01 if world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill + rom.write_byte(0x180042, 0x01 if local_world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill # remove shield from uncle rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E]) @@ -1738,8 +1739,7 @@ def get_price_data(price: int, price_type: int) -> List[int]: def write_custom_shops(rom, world, player): - shops = sorted([shop for shop in world.shops if shop.custom and shop.region.player == player], - key=lambda shop: shop.sram_offset) + shops = sorted([shop for shop in world.worlds[player].shops if shop.custom], key=lambda shop: shop.sram_offset) shop_data = bytearray() items_data = bytearray() @@ -3023,7 +3023,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_settings() + options = settings.get_settings() if not file_name: file_name = options["lttp_options"]["rom_file"] if not os.path.exists(file_name): diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index 2d11d537fb..a5b14e0c2d 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -147,7 +147,6 @@ def set_defeat_dungeon_boss_rule(location): add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state)) - def set_always_allow(spot, rule): spot.always_allow = rule @@ -463,12 +462,15 @@ def global_rules(multiworld: MultiWorld, player: int): set_rule(multiworld.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player)) set_rule(multiworld.get_location('Misery Mire - Spike Chest', player), lambda state: (world.can_take_damage and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player)) set_rule(multiworld.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('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(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2)) - set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2)) + + # The most number of keys you can burn without opening the map chest and without reaching a crystal switch is 1, + # but if you cannot activate a crystal switch except by throwing a pot, you could burn another two going through + # the conveyor crystal room. + set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 2) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 4)) + # Using a key on the map door chest will get you the map chest but not a crystal switch. Main Lobby should require + # one more key. + set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 3) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 5)) + # 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(multiworld.get_location('Misery Mire - Conveyor Crystal Key Drop', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 4) @@ -542,6 +544,8 @@ def global_rules(multiworld: MultiWorld, player: int): set_rule(multiworld.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player)) set_rule(multiworld.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player)) set_rule(multiworld.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(multiworld.get_location('Ganons Tower - Double Switch Pot Key', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player)) + set_rule(multiworld.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player)) if world.options.pot_shuffle: set_rule(multiworld.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(multiworld.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or ( @@ -975,18 +979,19 @@ def check_is_dark_world(region): return False -def add_conditional_lamps(world, player): +def add_conditional_lamps(multiworld, player): # Light cones in standard depend on which world we actually are in, not which one the location would normally be # We add Lamp requirements only to those locations which lie in the dark world (or everything if open + local_world = multiworld.worlds[player] def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=False): - if (not world.dark_world_light_cone and check_is_dark_world(world.get_region(region, player))) or ( - not world.light_world_light_cone and not check_is_dark_world(world.get_region(region, player))): + if (not local_world.dark_world_light_cone and check_is_dark_world(local_world.get_region(region))) or ( + not local_world.light_world_light_cone and not check_is_dark_world(local_world.get_region(region))): if spottype == 'Location': - spot = world.get_location(spot, player) + spot = local_world.get_location(spot) else: - spot = world.get_entrance(spot, player) - add_lamp_requirement(world, spot, player, accessible_torch) + spot = local_world.get_entrance(spot) + add_lamp_requirement(multiworld, spot, player, accessible_torch) add_conditional_lamp('Misery Mire (Vitreous)', 'Misery Mire (Entrance)', 'Entrance') add_conditional_lamp('Turtle Rock (Dark Room) (North)', 'Turtle Rock (Entrance)', 'Entrance') @@ -997,7 +1002,7 @@ def add_conditional_lamps(world, player): 'Location', True) add_conditional_lamp('Palace of Darkness - Dark Basement - Right', 'Palace of Darkness (Entrance)', 'Location', True) - if world.worlds[player].options.mode != 'inverted': + if multiworld.worlds[player].options.mode != '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') @@ -1019,10 +1024,10 @@ def add_conditional_lamps(world, player): add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True) add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True) - if not world.worlds[player].options.mode == "standard": - add_lamp_requirement(world, world.get_location('Sewers - Dark Cross', player), player) - add_lamp_requirement(world, world.get_entrance('Sewers Back Door', player), player) - add_lamp_requirement(world, world.get_entrance('Throne Room', player), player) + if not multiworld.worlds[player].options.mode == "standard": + add_lamp_requirement(multiworld, local_world.get_location("Sewers - Dark Cross"), player) + add_lamp_requirement(multiworld, local_world.get_entrance("Sewers Back Door"), player) + add_lamp_requirement(multiworld, local_world.get_entrance("Throne Room"), player) def open_rules(world, player): diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index bb3945f5b0..89e43a1a04 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -14,8 +14,6 @@ from .Items import item_name_groups from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows -logger = logging.getLogger("Shops") - @unique class ShopType(IntEnum): @@ -162,7 +160,10 @@ shop_class_mapping = {ShopType.UpgradeShop: UpgradeShop, def push_shop_inventories(multiworld): - shop_slots = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if shop.type + all_shops = [] + for world in multiworld.get_game_worlds(ALttPLocation.game): + all_shops.extend(world.shops) + shop_slots = [location for shop_locations in (shop.region.locations for shop in all_shops if shop.type != ShopType.TakeAny) for location in shop_locations if location.shop_slot is not None] for location in shop_slots: @@ -178,7 +179,7 @@ def push_shop_inventories(multiworld): get_price(multiworld, location.shop.inventory[location.shop_slot], location.player, location.shop_price_type)[1]) - for world in multiworld.get_game_worlds("A Link to the Past"): + for world in multiworld.get_game_worlds(ALttPLocation.game): world.pushed_shop_inventories.set() @@ -225,7 +226,7 @@ def create_shops(multiworld, player: int): if locked is None: shop.locked = True region.shop = shop - multiworld.shops.append(shop) + multiworld.worlds[player].shops.append(shop) for index, item in enumerate(inventory): shop.add_inventory(index, *item) if not locked and (num_slots or type == ShopType.UpgradeShop): @@ -309,50 +310,50 @@ def set_up_shops(multiworld, player: int): from .Options import small_key_shuffle # TODO: move hard+ mode changes for shields here, utilizing the new shops - if multiworld.worlds[player].options.retro_bow: + local_world = multiworld.worlds[player] + + if local_world.options.retro_bow: rss = multiworld.get_region('Red Shield Shop', player).shop + # Can't just replace the single arrow with 10 arrows as retro doesn't need them. replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50], - ['Blue Shield', 50], ['Small Heart', - 10]] # Can't just replace the single arrow with 10 arrows as retro doesn't need them. - if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal: + ['Blue Shield', 50], ['Small Heart', 10]] + if local_world.options.small_key_shuffle == small_key_shuffle.option_universal: replacement_items.append(['Small Key (Universal)', 100]) replacement_item = multiworld.random.choice(replacement_items) rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1]) rss.locked = True - if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal or multiworld.worlds[player].options.retro_bow: - for shop in multiworld.random.sample([s for s in multiworld.shops if - s.custom and not s.locked and s.type == ShopType.Shop - and s.region.player == player], 5): + if local_world.options.small_key_shuffle == small_key_shuffle.option_universal or local_world.options.retro_bow: + for shop in multiworld.random.sample([s for s in local_world.shops if + s.custom and not s.locked and s.type == ShopType.Shop], 5): shop.locked = True slots = [0, 1, 2] multiworld.random.shuffle(slots) slots = iter(slots) - if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal: + if local_world.options.small_key_shuffle == small_key_shuffle.option_universal: shop.add_inventory(next(slots), 'Small Key (Universal)', 100) - if multiworld.worlds[player].options.retro_bow: + if local_world.options.retro_bow: shop.push_inventory(next(slots), 'Single Arrow', 80) - if multiworld.worlds[player].options.shuffle_capacity_upgrades: - for shop in multiworld.shops: - if shop.type == ShopType.UpgradeShop and shop.region.player == player and \ + if local_world.options.shuffle_capacity_upgrades: + for shop in local_world.shops: + if shop.type == ShopType.UpgradeShop and \ shop.region.name == "Capacity Upgrade": shop.clear_inventory() - if (multiworld.worlds[player].options.shuffle_shop_inventories or multiworld.worlds[player].options.randomize_shop_prices - or multiworld.worlds[player].options.randomize_cost_types): + if (local_world.options.shuffle_shop_inventories or local_world.options.randomize_shop_prices + or local_world.options.randomize_cost_types): shops = [] total_inventory = [] - for shop in multiworld.shops: - if shop.region.player == player: - if shop.type == ShopType.Shop and not shop.locked: - shops.append(shop) - total_inventory.extend(shop.inventory) + for shop in local_world.shops: + if shop.type == ShopType.Shop and not shop.locked: + shops.append(shop) + total_inventory.extend(shop.inventory) for item in total_inventory: item["price_type"], item["price"] = get_price(multiworld, item, player) - if multiworld.worlds[player].options.shuffle_shop_inventories: + if local_world.options.shuffle_shop_inventories: multiworld.random.shuffle(total_inventory) i = 0 @@ -407,7 +408,7 @@ price_rate_display = { } -def get_price_modifier(item): +def get_price_modifier(item) -> float: if item.game == "A Link to the Past": if any(x in item.name for x in ['Compass', 'Map', 'Single Bomb', 'Single Arrow', 'Piece of Heart']): @@ -418,9 +419,9 @@ def get_price_modifier(item): elif any(x in item.name for x in ['Small Key', 'Heart']): return 0.5 else: - return 1 + return 1.0 if item.advancement: - return 1 + return 1.0 elif item.useful: return 0.5 else: @@ -471,7 +472,7 @@ def get_price(multiworld, item, player: int, price_type=None): def shop_price_rules(state: CollectionState, player: int, location: ALttPLocation): if location.shop_price_type == ShopPriceType.Hearts: - return has_hearts(state, player, (location.shop_price / 8) + 1) + return has_hearts(state, player, (location.shop_price // 8) + 1) elif location.shop_price_type == ShopPriceType.Bombs: return can_use_bombs(state, player, location.shop_price) elif location.shop_price_type == ShopPriceType.Arrows: diff --git a/worlds/alttp/StateHelpers.py b/worlds/alttp/StateHelpers.py index 6ac3c4b8f8..98409c8a8d 100644 --- a/worlds/alttp/StateHelpers.py +++ b/worlds/alttp/StateHelpers.py @@ -14,13 +14,13 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for - shop in state.multiworld.shops) + return any(shop.has_unlimited(item) and shop.region.can_reach(state) for + shop in state.multiworld.worlds[player].shops) def can_buy(state: CollectionState, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for - shop in state.multiworld.shops) + return any(shop.has(item) and shop.region.can_reach(state) for + shop in state.multiworld.worlds[player].shops) def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool: diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 7f8d6ddf68..4ee5b9d266 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -236,6 +236,8 @@ class ALTTPWorld(World): required_client_version = (0, 4, 1) web = ALTTPWeb() + shops: list[Shop] + pedestal_credit_texts: typing.Dict[int, str] = \ {data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit} sickkid_credit_texts: typing.Dict[int, str] = \ @@ -282,6 +284,10 @@ class ALTTPWorld(World): clock_mode: str = "" treasure_hunt_required: int = 0 treasure_hunt_total: int = 0 + light_world_light_cone: bool = False + dark_world_light_cone: bool = False + save_and_quit_from_boss: bool = True + rupoor_cost: int = 10 def __init__(self, *args, **kwargs): self.dungeon_local_item_names = set() @@ -298,6 +304,7 @@ class ALTTPWorld(World): self.fix_trock_exit = None self.required_medallions = ["Ether", "Quake"] self.escape_assist = [] + self.shops = [] super(ALTTPWorld, self).__init__(*args, **kwargs) @classmethod @@ -505,10 +512,11 @@ class ALTTPWorld(World): def pre_fill(self): from Fill import fill_restrictive, FillError attempts = 5 - all_state = self.multiworld.get_all_state(use_cache=False) + all_state = self.multiworld.get_all_state(perform_sweep=False) crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']] for crystal in crystals: all_state.remove(crystal) + all_state.sweep_for_advancements() crystal_locations = [self.get_location('Turtle Rock - Prize'), self.get_location('Eastern Palace - Prize'), self.get_location('Desert Palace - Prize'), @@ -799,7 +807,7 @@ class ALTTPWorld(World): return shop_data - if shop_info := [build_shop_info(shop) for shop in self.multiworld.shops if shop.custom]: + if shop_info := [build_shop_info(shop) for shop in self.shops if shop.custom]: spoiler_handle.write('\n\nShops:\n\n') for shop_data in shop_info: spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join( diff --git a/worlds/alttp/test/dungeons/TestMiseryMire.py b/worlds/alttp/test/dungeons/TestMiseryMire.py index 90b7055b76..b44d7d1bee 100644 --- a/worlds/alttp/test/dungeons/TestMiseryMire.py +++ b/worlds/alttp/test/dungeons/TestMiseryMire.py @@ -32,8 +32,8 @@ class TestMiseryMire(TestDungeon): ["Misery Mire - Main Lobby", False, []], ["Misery Mire - Main Lobby", False, [], ['Pegasus Boots', 'Hookshot']], ["Misery Mire - Main Lobby", False, [], ['Small Key (Misery Mire)', 'Big Key (Misery Mire)']], - ["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']], - ["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']], + ["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']], + ["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']], ["Misery Mire - Big Key Chest", False, []], ["Misery Mire - Big Key Chest", False, [], ['Fire Rod', 'Lamp']], diff --git a/worlds/alttp/test/options/test_dungeon_fill.py b/worlds/alttp/test/options/test_dungeon_fill.py index 17501b65d8..4a0d30f7d9 100644 --- a/worlds/alttp/test/options/test_dungeon_fill.py +++ b/worlds/alttp/test/options/test_dungeon_fill.py @@ -38,7 +38,7 @@ class DungeonFillTestBase(TestCase): def test_original_dungeons(self): self.generate_with_options(DungeonItem.option_original_dungeon) for location in self.multiworld.get_filled_locations(): - with (self.subTest(location=location)): + with (self.subTest(location_name=location.name)): if location.parent_region.dungeon is None: self.assertIs(location.item.dungeon, None) else: @@ -52,7 +52,7 @@ class DungeonFillTestBase(TestCase): def test_own_dungeons(self): self.generate_with_options(DungeonItem.option_own_dungeons) for location in self.multiworld.get_filled_locations(): - with self.subTest(location=location): + with self.subTest(location_name=location.name): if location.parent_region.dungeon is None: self.assertIs(location.item.dungeon, None) else: diff --git a/worlds/aquaria/Regions.py b/worlds/aquaria/Regions.py index 40170e0c32..3436374ac7 100755 --- a/worlds/aquaria/Regions.py +++ b/worlds/aquaria/Regions.py @@ -4,7 +4,7 @@ Date: Fri, 15 Mar 2024 18:41:40 +0000 Description: Used to manage Regions in the Aquaria game multiworld randomizer """ -from typing import Dict, Optional +from typing import Dict, Optional, Iterable from BaseClasses import MultiWorld, Region, Entrance, Item, ItemClassification, CollectionState from .Items import AquariaItem, ItemNames from .Locations import AquariaLocations, AquariaLocation, AquariaLocationNames @@ -34,10 +34,15 @@ def _has_li(state: CollectionState, player: int) -> bool: return state.has(ItemNames.LI_AND_LI_SONG, player) -def _has_damaging_item(state: CollectionState, player: int) -> bool: - """`player` in `state` has the shield song item""" - return state.has_any({ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG, - ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.BABY_BLASTER}, player) +DAMAGING_ITEMS:Iterable[str] = [ + ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, + ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, + ItemNames.BABY_BLASTER +] + +def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool: + """`player` in `state` has the an item that do damage other than the ones in `to_remove`""" + return state.has_any(damaging_items, player) def _has_energy_attack_item(state: CollectionState, player: int) -> bool: @@ -566,9 +571,11 @@ class AquariaRegions: self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_turtle, lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) self.__connect_one_way_regions(self.openwater_tr_turtle, self.openwater_tr) + damaging_items_minus_nature_form = [item for item in DAMAGING_ITEMS if item != ItemNames.NATURE_FORM] self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_urns, lambda state: _has_bind_song(state, self.player) or - _has_damaging_item(state, self.player)) + _has_damaging_item(state, self.player, + damaging_items_minus_nature_form)) self.__connect_regions(self.openwater_tr, self.openwater_br) self.__connect_regions(self.openwater_tr, self.mithalas_city) self.__connect_regions(self.openwater_tr, self.veil_b) diff --git a/worlds/blasphemous/Options.py b/worlds/blasphemous/Options.py index 2cb2d8a1d3..74eb1139ce 100644 --- a/worlds/blasphemous/Options.py +++ b/worlds/blasphemous/Options.py @@ -207,11 +207,7 @@ class EnemyScaling(DefaultOnToggle): class BlasphemousDeathLink(DeathLink): - """ - When you die, everyone dies. The reverse is also true. - - Note that Guilt Fragments will not appear when killed by Death Link. - """ + __doc__ = DeathLink.__doc__ + "\n\n Note that Guilt Fragments will not appear when killed by death link." @dataclass diff --git a/worlds/bomb_rush_cyberfunk/Options.py b/worlds/bomb_rush_cyberfunk/Options.py index 80831d0645..fd327d48ec 100644 --- a/worlds/bomb_rush_cyberfunk/Options.py +++ b/worlds/bomb_rush_cyberfunk/Options.py @@ -175,11 +175,7 @@ class DamageMultiplier(Range): class BRCDeathLink(DeathLink): - """ - When you die, everyone dies. The reverse is also true. - - This can be changed later in the options menu inside the Archipelago phone app. - """ + __doc__ = DeathLink.__doc__ + "\n\n This can be changed later in the options menu inside the Archipelago phone app." @dataclass diff --git a/worlds/civ_6/Container.py b/worlds/civ_6/Container.py index 0c5340d9c2..a5790c1ec4 100644 --- a/worlds/civ_6/Container.py +++ b/worlds/civ_6/Container.py @@ -1,10 +1,9 @@ from dataclasses import dataclass import os -import io from typing import TYPE_CHECKING, Dict, List, Optional, cast import zipfile from BaseClasses import Location -from worlds.Files import APContainer, AutoPatchRegister +from worlds.Files import APPlayerContainer from .Enum import CivVICheckType from .Locations import CivVILocation, CivVILocationData @@ -26,22 +25,19 @@ class CivTreeItem: ui_tree_row: int -class CivVIContainer(APContainer, metaclass=AutoPatchRegister): +class CivVIContainer(APPlayerContainer): """ Responsible for generating the dynamic mod files for the Civ VI multiworld """ game: Optional[str] = "Civilization VI" patch_file_ending = ".apcivvi" - def __init__(self, patch_data: Dict[str, str] | io.BytesIO, base_path: str = "", output_directory: str = "", + def __init__(self, patch_data: Dict[str, str], base_path: str = "", output_directory: str = "", player: Optional[int] = None, player_name: str = "", server: str = ""): - if isinstance(patch_data, io.BytesIO): - super().__init__(patch_data, player, player_name, server) - else: - self.patch_data = patch_data - self.file_path = base_path - container_path = os.path.join(output_directory, base_path + ".apcivvi") - super().__init__(container_path, player, player_name, server) + self.patch_data = patch_data + self.file_path = base_path + container_path = os.path.join(output_directory, base_path + ".apcivvi") + super().__init__(container_path, player, player_name, server) def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None: for filename, yml in self.patch_data.items(): diff --git a/worlds/civ_6/data/boosts.py b/worlds/civ_6/data/boosts.py index a397720815..49cedfdfd9 100644 --- a/worlds/civ_6/data/boosts.py +++ b/worlds/civ_6/data/boosts.py @@ -78,8 +78,8 @@ boosts: List[CivVIBoostData] = [ CivVIBoostData( "BOOST_TECH_IRON_WORKING", "ERA_CLASSICAL", - ["TECH_MINING"], - 1, + ["TECH_MINING", "TECH_BRONZE_WORKING"], + 2, "DEFAULT", ), CivVIBoostData( @@ -165,15 +165,9 @@ boosts: List[CivVIBoostData] = [ "BOOST_TECH_CASTLES", "ERA_MEDIEVAL", [ - "CIVIC_DIVINE_RIGHT", - "CIVIC_EXPLORATION", - "CIVIC_REFORMED_CHURCH", "CIVIC_SUFFRAGE", "CIVIC_TOTALITARIANISM", "CIVIC_CLASS_STRUGGLE", - "CIVIC_DIGITAL_DEMOCRACY", - "CIVIC_CORPORATE_LIBERTARIANISM", - "CIVIC_SYNTHETIC_TECHNOCRACY", ], 1, "DEFAULT", @@ -393,9 +387,6 @@ boosts: List[CivVIBoostData] = [ "CIVIC_SUFFRAGE", "CIVIC_TOTALITARIANISM", "CIVIC_CLASS_STRUGGLE", - "CIVIC_DIGITAL_DEMOCRACY", - "CIVIC_CORPORATE_LIBERTARIANISM", - "CIVIC_SYNTHETIC_TECHNOCRACY", ], 1, "DEFAULT", diff --git a/worlds/civ_6/docs/en_Civilization VI.md b/worlds/civ_6/docs/en_Civilization VI.md index 215da00aa4..2a5f5fbd73 100644 --- a/worlds/civ_6/docs/en_Civilization VI.md +++ b/worlds/civ_6/docs/en_Civilization VI.md @@ -20,16 +20,17 @@ A short period after receiving an item, you will get a notification indicating y ## FAQs - Do I need the DLC to play this? - - Yes, you need both Rise & Fall and Gathering Storm. + - You need both expansions, Rise & Fall and Gathering Storm. You do not need the other DLCs but they fully work with this. - Does this work with Multiplayer? - It does not and, despite my best efforts, probably won't until there's a new way for external programs to be able to interact with the game. -- Does my mod that reskins Barbarians as various Pro Wrestlers work with this? - - Only one way to find out! Any mods that modify techs/civics will most likely cause issues, though. +- Does this work with other mods? + - A lot of mods seem to work without issues combined with this, but you should avoid any mods that change things in the tech or civic tree, as even if they would work it could cause issues with the logic. - "Help! I can't see any of the items that have been sent to me!" - Both trees by default will show you the researchable Archipelago locations. To view the normal tree, you can click "Toggle Archipelago Tree" in the top-left corner of the tree view. - "Oh no! I received the Machinery tech and now instead of getting an Archer next turn, I have to wait an additional 10 turns to get a Crossbowman!" - Vanilla prevents you from building units of the same class from an earlier tech level after you have researched a later variant. For example, this could be problematic if someone unlocks Crossbowmen for you right out the gate since you won't be able to make Archers (which have a much lower production cost). -Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to view your unlocked techs, and then can click any tech you have unlocked to toggle whether it is currently active or not. + - Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to view your unlocked techs, and then can click any tech you have unlocked to toggle whether it is currently active or not. + - If you think you should be able to make Field Cannons but seemingly can't try disabling `Telecommunications` - "How does DeathLink work? Am I going to have to start a new game every time one of my friends dies?" - Heavens no, my fellow Archipelago appreciator. When configuring your Archipelago options for Civilization on the options page, there are several choices available for you to fine tune the way you'd like to be punished for the follies of your friends. These include: Having a random unit destroyed, losing a percentage of gold or faith, or even losing a point on your era score. If you can't make up your mind, you can elect to have any of them be selected every time a death link is sent your way. In the event you lose one of your units in combat (this means captured units don't count), then you will send a death link event to the rest of your friends. @@ -39,7 +40,8 @@ Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to 1. `TECH_WRITING` 2. `TECH_EDUCATION` 3. `TECH_CHEMISTRY` - - If you want to see the details around each item, you can review [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/progressive_districts.json). + - An important thing to note is that the seaport is part of progressive industrial zones, due to electricity having both an industrial zone building and the seaport. + - If you want to see the details around each item, you can review [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/progressive_districts.py). ## Boostsanity Boostsanity takes all of the Eureka & Inspiration events and makes them location checks. This feature is the one to change up the way Civilization is played in an AP multiworld/randomizer. What normally are mundane tasks that are passively collected now become a novel and interesting bucket list that you need to pay attention to in order to unlock items for yourself and others! @@ -56,4 +58,3 @@ Boosts have logic associated with them in order to verify you can always reach t - The unpredictable timing of boosts and unlocking them can occasionally lead to scenarios where you'll have to first encounter a locked era defeat and then load a previous save. To help reduce the frequency of this, local `PROGRESSIVE_ERA` items will never be located at a boost check. - There's too many boosts, how will I know which one's I should focus on?! - In order to give a little more focus to all the boosts rather than just arbitrarily picking them at random, items in both of the vanilla trees will now have an advisor icon on them if its associated boost contains a progression item. - diff --git a/worlds/civ_6/docs/setup_en.md b/worlds/civ_6/docs/setup_en.md index 9cf4744b65..fb8404190f 100644 --- a/worlds/civ_6/docs/setup_en.md +++ b/worlds/civ_6/docs/setup_en.md @@ -6,12 +6,14 @@ This guide is meant to help you get up and running with Civilization VI in Archi The following are required in order to play Civ VI in Archipelago: -- Windows OS (Firaxis does not support the necessary tooling for Mac, or Linux) +- Windows OS (Firaxis does not support the necessary tooling for Mac, or Linux). -- Installed [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) v0.4.5 or higher. +- Installed [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). - The latest version of the [Civ VI AP Mod](https://github.com/hesto2/civilization_archipelago_mod/releases/latest). +- A copy of the game `Civilization VI` including the two expansions `Rise & Fall` and `Gathering Storm` (both the Steam and Epic version should work). + ## Enabling the tuner In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled. @@ -20,27 +22,32 @@ In the main menu, navigate to the "Game Options" page. On the "Game" menu, make 1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest). -2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure. +2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure, and use that path when relevant in future steps. 3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it. -4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can just rename it to a file ending with `.zip` and extract its contents to a new folder. To do this, right click the `.apcivvi` file and click "Rename", make sure it ends in `.zip`, then right click it again and select "Extract All". +4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can instead open it as a zip file. You can do this by either right clicking it and opening it with a program that handles zip files, or by right clicking and renaming the file extension from `apcivvi` to `zip`. -5. Your finished mod folder should look something like this: - -- Civ VI Mods Directory - - civilization_archipelago_mod - - NewItems.xml - - InitOptions.lua - - Archipelago.modinfo - - All the other mod files, etc. +5. Place the files generated from the `.apcivvi` in your archipelago mod folder (there should be five files placed there from the apcivvi file, overwrite if asked). Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`. ## Configuring your game -When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would. +Make sure you enable the mod in the main title under Additional Content > Mods. When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would. ## Troubleshooting +- If you have troubles with file extension related stuff, make sure Windows shows file extensions as they are turned off by default. If you don't know how to turn them on it is just a quick google search away. + - If you are getting an error: "The remote computer refused the network connection", or something else related to the client (or tuner) not being able to connect, it likely indicates the tuner is not actually enabled. One simple way to verify that it is enabled is, after completing the setup steps, go to Main Menu → Options → Look for an option named "Tuner" and verify it is set to "Enabled" -- If your game gets in a state where someone has sent you items or you have sent locations but these are not correctly sent to the multiworld, you can run `/resync` from the Civ 6 client. This may take up to a minute depending on how many items there are. +- If your game gets in a state where someone has sent you items or you have sent locations but these are not correctly sent to the multiworld, you can run `/resync` from the Civ 6 client. This may take up to a minute depending on how many items there are. This can resend certain items to you, like one time bonuses. + +- If the archipelago mod does not appear in the mod selector in the game, make sure the mod is correctly placed as a folder in the `Sid Meier's Civilization VI\Mods` folder, there should not be any loose files in there only folders. As in the path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`. + +- If it still does not appear make sure you have the right folder, one way to verify you are in the right place is to find the general folder area where your Civ VI save files are located. + +- If you get an error when trying to start a game saying `Error - One or more Mods failed to load content`, make sure the files from the `.apcivvi` are placed into the `civilization_archipelago_mod` as loose files and not as a folder. + +- If you still have any errors make sure the two expansions Rise & Fall and Gathering Storm are active in the mod selector (all the official DLC works without issues but Rise & Fall and Gathering Storm are required for the mod). + +- If boostsanity is enabled and those items are not being sent out but regular techs are, make sure you placed the files from your new room in the mod folder. diff --git a/worlds/clique/Items.py b/worlds/clique/Items.py deleted file mode 100644 index 81e2540bac..0000000000 --- a/worlds/clique/Items.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING - -from BaseClasses import Item, ItemClassification - -if TYPE_CHECKING: - from . import CliqueWorld - - -class CliqueItem(Item): - game = "Clique" - - -class CliqueItemData(NamedTuple): - code: Optional[int] = None - type: ItemClassification = ItemClassification.filler - can_create: Callable[["CliqueWorld"], bool] = lambda world: True - - -item_data_table: Dict[str, CliqueItemData] = { - "Feeling of Satisfaction": CliqueItemData( - code=69696969, - type=ItemClassification.progression, - ), - "Button Activation": CliqueItemData( - code=69696968, - type=ItemClassification.progression, - can_create=lambda world: world.options.hard_mode, - ), - "A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData( - code=69696967, - can_create=lambda world: False # Only created from `get_filler_item_name`. - ), - "The Urge to Push": CliqueItemData( - type=ItemClassification.progression, - ), -} - -item_table = {name: data.code for name, data in item_data_table.items() if data.code is not None} diff --git a/worlds/clique/Locations.py b/worlds/clique/Locations.py deleted file mode 100644 index 900b497eb4..0000000000 --- a/worlds/clique/Locations.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING - -from BaseClasses import Location - -if TYPE_CHECKING: - from . import CliqueWorld - - -class CliqueLocation(Location): - game = "Clique" - - -class CliqueLocationData(NamedTuple): - region: str - address: Optional[int] = None - can_create: Callable[["CliqueWorld"], bool] = lambda world: True - locked_item: Optional[str] = None - - -location_data_table: Dict[str, CliqueLocationData] = { - "The Big Red Button": CliqueLocationData( - region="The Button Realm", - address=69696969, - ), - "The Item on the Desk": CliqueLocationData( - region="The Button Realm", - address=69696968, - can_create=lambda world: world.options.hard_mode, - ), - "In the Player's Mind": CliqueLocationData( - region="The Button Realm", - locked_item="The Urge to Push", - ), -} - -location_table = {name: data.address for name, data in location_data_table.items() if data.address is not None} -locked_locations = {name: data for name, data in location_data_table.items() if data.locked_item} diff --git a/worlds/clique/Options.py b/worlds/clique/Options.py deleted file mode 100644 index d88a128990..0000000000 --- a/worlds/clique/Options.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from Options import Choice, Toggle, PerGameCommonOptions, StartInventoryPool - - -class HardMode(Toggle): - """Only for the most masochistically inclined... Requires button activation!""" - display_name = "Hard Mode" - - -class ButtonColor(Choice): - """Customize your button! Now available in 12 unique colors.""" - display_name = "Button Color" - option_red = 0 - option_orange = 1 - option_yellow = 2 - option_green = 3 - option_cyan = 4 - option_blue = 5 - option_magenta = 6 - option_purple = 7 - option_pink = 8 - option_brown = 9 - option_white = 10 - option_black = 11 - - -@dataclass -class CliqueOptions(PerGameCommonOptions): - color: ButtonColor - hard_mode: HardMode - start_inventory_from_pool: StartInventoryPool - - # DeathLink is always on. Always. - # death_link: DeathLink diff --git a/worlds/clique/Regions.py b/worlds/clique/Regions.py deleted file mode 100644 index 04e317067f..0000000000 --- a/worlds/clique/Regions.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Dict, List, NamedTuple - - -class CliqueRegionData(NamedTuple): - connecting_regions: List[str] = [] - - -region_data_table: Dict[str, CliqueRegionData] = { - "Menu": CliqueRegionData(["The Button Realm"]), - "The Button Realm": CliqueRegionData(), -} diff --git a/worlds/clique/Rules.py b/worlds/clique/Rules.py deleted file mode 100644 index 63ecd4e9e1..0000000000 --- a/worlds/clique/Rules.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Callable, TYPE_CHECKING - -from BaseClasses import CollectionState - -if TYPE_CHECKING: - from . import CliqueWorld - - -def get_button_rule(world: "CliqueWorld") -> Callable[[CollectionState], bool]: - if world.options.hard_mode: - return lambda state: state.has("Button Activation", world.player) - - return lambda state: True diff --git a/worlds/clique/__init__.py b/worlds/clique/__init__.py deleted file mode 100644 index 70777c51b0..0000000000 --- a/worlds/clique/__init__.py +++ /dev/null @@ -1,102 +0,0 @@ -from typing import List, Dict, Any - -from BaseClasses import Region, Tutorial -from worlds.AutoWorld import WebWorld, World -from .Items import CliqueItem, item_data_table, item_table -from .Locations import CliqueLocation, location_data_table, location_table, locked_locations -from .Options import CliqueOptions -from .Regions import region_data_table -from .Rules import get_button_rule - - -class CliqueWebWorld(WebWorld): - theme = "partyTime" - - setup_en = Tutorial( - tutorial_name="Start Guide", - description="A guide to playing Clique.", - language="English", - file_name="guide_en.md", - link="guide/en", - authors=["Phar"] - ) - - setup_de = Tutorial( - tutorial_name="Anleitung zum Anfangen", - description="Eine Anleitung um Clique zu spielen.", - language="Deutsch", - file_name="guide_de.md", - link="guide/de", - authors=["Held_der_Zeit"] - ) - - tutorials = [setup_en, setup_de] - game_info_languages = ["en", "de"] - - -class CliqueWorld(World): - """The greatest game of all time.""" - - game = "Clique" - web = CliqueWebWorld() - options: CliqueOptions - options_dataclass = CliqueOptions - location_name_to_id = location_table - item_name_to_id = item_table - - def create_item(self, name: str) -> CliqueItem: - return CliqueItem(name, item_data_table[name].type, item_data_table[name].code, self.player) - - def create_items(self) -> None: - item_pool: List[CliqueItem] = [] - for name, item in item_data_table.items(): - if item.code and item.can_create(self): - item_pool.append(self.create_item(name)) - - self.multiworld.itempool += item_pool - - def create_regions(self) -> None: - # Create regions. - for region_name in region_data_table.keys(): - region = Region(region_name, self.player, self.multiworld) - self.multiworld.regions.append(region) - - # Create locations. - for region_name, region_data in region_data_table.items(): - region = self.get_region(region_name) - region.add_locations({ - location_name: location_data.address for location_name, location_data in location_data_table.items() - if location_data.region == region_name and location_data.can_create(self) - }, CliqueLocation) - region.add_exits(region_data_table[region_name].connecting_regions) - - # Place locked locations. - for location_name, location_data in locked_locations.items(): - # Ignore locations we never created. - if not location_data.can_create(self): - continue - - locked_item = self.create_item(location_data_table[location_name].locked_item) - self.get_location(location_name).place_locked_item(locked_item) - - # Set priority location for the Big Red Button! - self.options.priority_locations.value.add("The Big Red Button") - - def get_filler_item_name(self) -> str: - return "A Cool Filler Item (No Satisfaction Guaranteed)" - - def set_rules(self) -> None: - button_rule = get_button_rule(self) - self.get_location("The Big Red Button").access_rule = button_rule - self.get_location("In the Player's Mind").access_rule = button_rule - - # Do not allow button activations on buttons. - self.get_location("The Big Red Button").item_rule = lambda item: item.name != "Button Activation" - - # Completion condition. - self.multiworld.completion_condition[self.player] = lambda state: state.has("The Urge to Push", self.player) - - def fill_slot_data(self) -> Dict[str, Any]: - return { - "color": self.options.color.current_key - } diff --git a/worlds/clique/docs/de_Clique.md b/worlds/clique/docs/de_Clique.md deleted file mode 100644 index cde0a23cf6..0000000000 --- a/worlds/clique/docs/de_Clique.md +++ /dev/null @@ -1,18 +0,0 @@ -# Clique - -## Was ist das für ein Spiel? - -~~Clique ist ein psychologisches Überlebens-Horror Spiel, in dem der Spieler der Versuchung wiederstehen muss große~~ -~~(rote) Knöpfe zu drücken.~~ - -Clique ist ein scherzhaftes Spiel, welches für Archipelago im März 2023 entwickelt wurde, um zu zeigen, wie einfach -es sein kann eine Welt für Archipelago zu entwicklen. Das Ziel des Spiels ist es den großen (standardmäßig) roten -Knopf zu drücken. Wenn ein Spieler auf dem `hard_mode` (schwieriger Modus) spielt, muss dieser warten bis jemand -anderes in der Multiworld den Knopf aktiviert, damit er gedrückt werden kann. - -Clique kann auf den meisten modernen, HTML5-fähigen Browsern gespielt werden. - -## Wo ist die Seite für die Einstellungen? - -Die [Seite für die Spielereinstellungen dieses Spiels](../player-options) enthält alle Optionen die man benötigt um -eine YAML-Datei zu konfigurieren und zu exportieren. diff --git a/worlds/clique/docs/en_Clique.md b/worlds/clique/docs/en_Clique.md deleted file mode 100644 index e9cb164fec..0000000000 --- a/worlds/clique/docs/en_Clique.md +++ /dev/null @@ -1,16 +0,0 @@ -# Clique - -## What is this game? - -~~Clique is a psychological survival horror game where a player must survive the temptation to press red buttons.~~ - -Clique is a joke game developed for Archipelago in March 2023 to showcase how easy it can be to develop a world for -Archipelago. The objective of the game is to press the big red button. If a player is playing on `hard_mode`, they must -wait for someone else in the multiworld to "activate" their button before they can press it. - -Clique can be played on most modern HTML5-capable browsers. - -## Where is the options page? - -The [player options page for this game](../player-options) contains all the options you need to configure -and export a config file. diff --git a/worlds/clique/docs/guide_de.md b/worlds/clique/docs/guide_de.md deleted file mode 100644 index 26e08dbbdd..0000000000 --- a/worlds/clique/docs/guide_de.md +++ /dev/null @@ -1,25 +0,0 @@ -# Clique Anleitung - -Nachdem dein Seed generiert wurde, gehe auf die Website von [Clique dem Spiel](http://clique.pharware.com/) und gib -Server-Daten, deinen Slot-Namen und ein Passwort (falls vorhanden) ein. Klicke dann auf "Connect" (Verbinden). - -Wenn du auf "Einfach" spielst, kannst du unbedenklich den Knopf drücken und deine "Befriedigung" erhalten. - -Wenn du auf "Schwer" spielst, ist es sehr wahrscheinlich, dass du warten musst bevor du dein Ziel erreichen kannst. -Glücklicherweise läuft Click auf den meißten großen Browsern, die HTML5 unterstützen. Das heißt du kannst Clique auf -deinem Handy starten und produktiv sein während du wartest! - -Falls du einige Ideen brauchst was du tun kannst, während du wartest bis der Knopf aktiviert wurde, versuche -(mindestens) eins der Folgenden: - -- Dein Zimmer aufräumen. -- Die Wäsche machen. -- Etwas Essen von einem X-Belieben Fast Food Restaruant holen. -- Das tägliche Wordle machen. -- ~~Deine Seele an **Phar** verkaufen.~~ -- Deine Hausaufgaben erledigen. -- Deine Post abholen. - - -~~Solltest du auf irgendwelche Probleme in diesem Spiel stoßen, solltest du keinesfalls nicht **thephar** auf~~ -~~Discord kontaktieren. *zwinker* *zwinker*~~ diff --git a/worlds/clique/docs/guide_en.md b/worlds/clique/docs/guide_en.md deleted file mode 100644 index c3c113fe90..0000000000 --- a/worlds/clique/docs/guide_en.md +++ /dev/null @@ -1,22 +0,0 @@ -# Clique Start Guide - -After rolling your seed, go to the [Clique Game](http://clique.pharware.com/) site and enter the server details, your -slot name, and a room password if one is required. Then click "Connect". - -If you're playing on "easy mode", just click the button and receive "Satisfaction". - -If you're playing on "hard mode", you may need to wait for activation before you can complete your objective. Luckily, -Clique runs in most major browsers that support HTML5, so you can load Clique on your phone and be productive while -you wait! - -If you need some ideas for what to do while waiting for button activation, give the following a try: - -- Clean your room. -- Wash the dishes. -- Get some food from a non-descript fast food restaurant. -- Do the daily Wordle. -- ~~Sell your soul to Phar.~~ -- Do your school work. - - -~~If you run into any issues with this game, definitely do not contact **thephar** on discord. *wink* *wink*~~ diff --git a/worlds/cv64/data/patches.py b/worlds/cv64/data/patches.py index 6ef4eafb67..4a964612f0 100644 --- a/worlds/cv64/data/patches.py +++ b/worlds/cv64/data/patches.py @@ -2893,3 +2893,18 @@ dog_bite_ice_trap_fix = [ 0x25291CB8, # ADDIU T1, T1, 0x1CB8 0x01200008 # JR T1 ] + +shimmy_speed_modifier = [ + # Increases the player's speed while shimmying as long as they are not holding down Z. If they are holding Z, it + # will be the normal speed, allowing it to still be used to set up any tricks that might require the normal speed + # (like Left Tower Skip). + 0x3C088038, # LUI T0, 0x8038 + 0x91087D7E, # LBU T0, 0x7D7E (T0) + 0x31090020, # ANDI T1, T0, 0x0020 + 0x3C0A800A, # LUI T2, 0x800A + 0x240B005A, # ADDIU T3, R0, 0x005A + 0x55200001, # BNEZL T1, [forward 0x01] + 0x240B0032, # ADDIU T3, R0, 0x0032 + 0xA14B3641, # SB T3, 0x3641 (T2) + 0x0800B7C3 # J 0x8002DF0C +] diff --git a/worlds/cv64/options.py b/worlds/cv64/options.py index 07e86347bd..62d7ec3369 100644 --- a/worlds/cv64/options.py +++ b/worlds/cv64/options.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from Options import (OptionGroup, Choice, DefaultOnToggle, ItemsAccessibility, PerGameCommonOptions, Range, Toggle, - StartInventoryPool) + StartInventoryPool, DeathLink) class CharacterStages(Choice): @@ -424,6 +424,7 @@ class PantherDash(Choice): class IncreaseShimmySpeed(Toggle): """ Increases the speed at which characters shimmy left and right while hanging on ledges. + Hold Z to use the regular speed in case it's needed to do something. """ display_name = "Increase Shimmy Speed" @@ -506,12 +507,11 @@ class WindowColorA(Range): default = 8 -class DeathLink(Choice): - """ - When you die, everyone dies. Of course the reverse is true too. - Explosive: Makes received DeathLinks kill you via the Magical Nitro explosion instead of the normal death animation. - """ - display_name = "DeathLink" +class CV64DeathLink(Choice): + __doc__ = (DeathLink.__doc__ + "\n\n Explosive: Makes received death links kill you via the Magical Nitro " + + "explosion instead of the normal death animation.") + + display_name = "Death Link" option_off = 0 alias_no = 0 alias_true = 1 @@ -574,7 +574,7 @@ class CV64Options(PerGameCommonOptions): map_lighting: MapLighting fall_guard: FallGuard cinematic_experience: CinematicExperience - death_link: DeathLink + death_link: CV64DeathLink cv64_option_groups = [ @@ -583,7 +583,7 @@ cv64_option_groups = [ RenonFightCondition, VincentFightCondition, BadEndingCondition, IncreaseItemLimit, NerfHealingItems, LoadingZoneHeals, InvisibleItems, DropPreviousSubWeapon, PermanentPowerUps, IceTrapPercentage, IceTrapAppearance, DisableTimeRestrictions, SkipGondolas, SkipWaterwayBlocks, Countdown, BigToss, PantherDash, - IncreaseShimmySpeed, FallGuard, DeathLink + IncreaseShimmySpeed, FallGuard, CV64DeathLink ]), OptionGroup("cosmetics", [ WindowColorR, WindowColorG, WindowColorB, WindowColorA, BackgroundMusic, MapLighting, CinematicExperience diff --git a/worlds/cv64/rom.py b/worlds/cv64/rom.py index 1833c7812b..a40d3ab300 100644 --- a/worlds/cv64/rom.py +++ b/worlds/cv64/rom.py @@ -16,7 +16,7 @@ from .text import cv64_string_to_bytearray, cv64_text_truncate, cv64_text_wrap from .aesthetics import renon_item_dialogue, get_item_text_color from .locations import get_location_info from .options import CharacterStages, VincentFightCondition, RenonFightCondition, PostBehemothBoss, RoomOfClocksBoss, \ - BadEndingCondition, DeathLink, DraculasCondition, InvisibleItems, Countdown, PantherDash + BadEndingCondition, CV64DeathLink, DraculasCondition, InvisibleItems, Countdown, PantherDash from settings import get_settings if TYPE_CHECKING: @@ -356,7 +356,7 @@ class CV64PatchExtensions(APPatchExtension): rom_data.write_int32s(0xBFE190, patches.subweapon_surface_checker) # Make received DeathLinks blow you to smithereens instead of kill you normally. - if options["death_link"] == DeathLink.option_explosive: + if options["death_link"] == CV64DeathLink.option_explosive: rom_data.write_int32s(0xBFC0D0, patches.deathlink_nitro_edition) rom_data.write_int32(0x27A70, 0x10000008) # B [forward 0x08] rom_data.write_int32(0x27AA0, 0x0C0FFA78) # JAL 0x803FE9E0 @@ -365,7 +365,7 @@ class CV64PatchExtensions(APPatchExtension): rom_data.write_int32(0x32DBC, 0x00000000) # Set the DeathLink ROM flag if it's on at all. - if options["death_link"] != DeathLink.option_off: + if options["death_link"] != CV64DeathLink.option_off: rom_data.write_byte(0xBFBFDE, 0x01) # DeathLink counter decrementer code @@ -607,9 +607,10 @@ class CV64PatchExtensions(APPatchExtension): rom_data.write_int32(0xAA530, 0x080FF880) # J 0x803FE200 rom_data.write_int32s(0xBFE200, patches.coffin_cutscene_skipper) - # Increase shimmy speed + # Shimmy speed increase hack if options["increase_shimmy_speed"]: - rom_data.write_byte(0xA4241, 0x5A) + rom_data.write_int32(0x97EB4, 0x803FE9F0) + rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier) # Disable landing fall damage if options["fall_guard"]: diff --git a/worlds/cvcotm/__init__.py b/worlds/cvcotm/__init__.py index 0f5077e709..a2d52b3ecc 100644 --- a/worlds/cvcotm/__init__.py +++ b/worlds/cvcotm/__init__.py @@ -211,7 +211,8 @@ class CVCotMWorld(World): "ignore_cleansing": self.options.ignore_cleansing.value, "skip_tutorials": self.options.skip_tutorials.value, "required_last_keys": self.required_last_keys, - "completion_goal": self.options.completion_goal.value} + "completion_goal": self.options.completion_goal.value, + "nerf_roc_wing": self.options.nerf_roc_wing.value} def get_filler_item_name(self) -> str: return self.random.choice(FILLER_ITEM_NAMES) diff --git a/worlds/cvcotm/aesthetics.py b/worlds/cvcotm/aesthetics.py index d1668b1db1..86134f8e09 100644 --- a/worlds/cvcotm/aesthetics.py +++ b/worlds/cvcotm/aesthetics.py @@ -48,11 +48,17 @@ class OtherGameAppearancesInfo(TypedDict): other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = { - # NOTE: Symphony of the Night is currently an unsupported world not in main. + # NOTE: Symphony of the Night and Harmony of Dissonance are custom worlds that are not core verified. "Symphony of the Night": {"Life Vessel": {"type": 0xE4, "appearance": 0x01}, "Heart Vessel": {"type": 0xE4, "appearance": 0x00}}, + + "Castlevania - Harmony of Dissonance": {"Life Max Up": {"type": 0xE4, + "appearance": 0x01}, + "Heart Max Up": {"type": 0xE4, + "appearance": 0x00}}, + "Timespinner": {"Max HP": {"type": 0xE4, "appearance": 0x01}, "Max Aura": {"type": 0xE4, @@ -728,8 +734,8 @@ def get_start_inventory_data(world: "CVCotMWorld") -> Tuple[Dict[int, bytes], bo magic_items_array[array_offset] += 1 # Add the start inventory arrays to the offset data in bytes form. - start_inventory_data[0x680080] = bytes(magic_items_array) - start_inventory_data[0x6800A0] = bytes(cards_array) + start_inventory_data[0x690080] = bytes(magic_items_array) + start_inventory_data[0x6900A0] = bytes(cards_array) # Add the extra max HP/MP/Hearts to all classes' base stats. Doing it this way makes us less likely to hit the max # possible Max Ups. diff --git a/worlds/cvcotm/data/patches.py b/worlds/cvcotm/data/patches.py index c2a9aa791f..37fba3bd40 100644 --- a/worlds/cvcotm/data/patches.py +++ b/worlds/cvcotm/data/patches.py @@ -132,40 +132,40 @@ start_inventory_giver = [ # Magic Items 0x13, 0x48, # ldr r0, =0x202572F - 0x14, 0x49, # ldr r1, =0x8680080 + 0x14, 0x49, # ldr r1, =0x8690080 0x00, 0x22, # mov r2, #0 0x8B, 0x5C, # ldrb r3, [r1, r2] 0x83, 0x54, # strb r3, [r0, r2] 0x01, 0x32, # adds r2, #1 0x08, 0x2A, # cmp r2, #8 - 0xFA, 0xDB, # blt 0x8680006 + 0xFA, 0xDB, # blt 0x8690006 # Max Ups 0x11, 0x48, # ldr r0, =0x202572C - 0x12, 0x49, # ldr r1, =0x8680090 + 0x12, 0x49, # ldr r1, =0x8690090 0x00, 0x22, # mov r2, #0 0x8B, 0x5C, # ldrb r3, [r1, r2] 0x83, 0x54, # strb r3, [r0, r2] 0x01, 0x32, # adds r2, #1 0x03, 0x2A, # cmp r2, #3 - 0xFA, 0xDB, # blt 0x8680016 + 0xFA, 0xDB, # blt 0x8690016 # Cards 0x0F, 0x48, # ldr r0, =0x2025674 - 0x10, 0x49, # ldr r1, =0x86800A0 + 0x10, 0x49, # ldr r1, =0x86900A0 0x00, 0x22, # mov r2, #0 0x8B, 0x5C, # ldrb r3, [r1, r2] 0x83, 0x54, # strb r3, [r0, r2] 0x01, 0x32, # adds r2, #1 0x14, 0x2A, # cmp r2, #0x14 - 0xFA, 0xDB, # blt 0x8680026 + 0xFA, 0xDB, # blt 0x8690026 # Inventory Items (not currently supported) 0x0D, 0x48, # ldr r0, =0x20256ED - 0x0E, 0x49, # ldr r1, =0x86800C0 + 0x0E, 0x49, # ldr r1, =0x86900C0 0x00, 0x22, # mov r2, #0 0x8B, 0x5C, # ldrb r3, [r1, r2] 0x83, 0x54, # strb r3, [r0, r2] 0x01, 0x32, # adds r2, #1 0x36, 0x2A, # cmp r2, #36 - 0xFA, 0xDB, # blt 0x8680036 + 0xFA, 0xDB, # blt 0x8690036 # Return to the function that checks for Magician Mode. 0xBA, 0x21, # movs r1, #0xBA 0x89, 0x00, # lsls r1, r1, #2 @@ -176,13 +176,13 @@ start_inventory_giver = [ # LDR number pool 0x78, 0x7F, 0x00, 0x08, 0x2F, 0x57, 0x02, 0x02, - 0x80, 0x00, 0x68, 0x08, + 0x80, 0x00, 0x69, 0x08, 0x2C, 0x57, 0x02, 0x02, - 0x90, 0x00, 0x68, 0x08, + 0x90, 0x00, 0x69, 0x08, 0x74, 0x56, 0x02, 0x02, - 0xA0, 0x00, 0x68, 0x08, + 0xA0, 0x00, 0x69, 0x08, 0xED, 0x56, 0x02, 0x02, - 0xC0, 0x00, 0x68, 0x08, + 0xC0, 0x00, 0x69, 0x08, ] max_max_up_checker = [ diff --git a/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md b/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md index 695c5f0ff9..611a1a376e 100644 --- a/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md +++ b/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md @@ -3,7 +3,7 @@ ## Quick Links - [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en) - [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options) -- [PopTracker Pack](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) +- [PopTracker Pack](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest) - [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer) - [Web version of the above randomizer](https://rando.circleofthemoon.com/) - [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing) diff --git a/worlds/cvcotm/docs/setup_en.md b/worlds/cvcotm/docs/setup_en.md index 459e0d6afb..4c34dcb836 100644 --- a/worlds/cvcotm/docs/setup_en.md +++ b/worlds/cvcotm/docs/setup_en.md @@ -22,7 +22,7 @@ clear it. ## Optional Software -- [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest), for use with +- [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest), for use with [PopTracker](https://github.com/black-sliver/PopTracker/releases). ## Generating and Patching a Game @@ -64,7 +64,7 @@ perfectly safe to make progress offline; everything will re-sync when you reconn Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking. -1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) and +1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest) and [PopTracker](https://github.com/black-sliver/PopTracker/releases). 2. Put the tracker pack into `packs/` in your PopTracker install. 3. Open PopTracker, and load the Castlevania: Circle of the Moon pack. diff --git a/worlds/cvcotm/rom.py b/worlds/cvcotm/rom.py index 6ae0b6e438..350829292b 100644 --- a/worlds/cvcotm/rom.py +++ b/worlds/cvcotm/rom.py @@ -335,8 +335,8 @@ class CVCotMPatchExtensions(APPatchExtension): rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener) # Give the player their Start Inventory upon entering their name on a new file. - rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x68, 0x08]) - rom_data.write_bytes(0x680000, patches.start_inventory_giver) + rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x69, 0x08]) + rom_data.write_bytes(0x690000, patches.start_inventory_giver) # Prevent Max Ups from exceeding 255. rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08]) diff --git a/worlds/dark_souls_3/Locations.py b/worlds/dark_souls_3/Locations.py index c84d91e516..b4e45fb577 100644 --- a/worlds/dark_souls_3/Locations.py +++ b/worlds/dark_souls_3/Locations.py @@ -884,7 +884,7 @@ location_tables: Dict[str, List[DS3LocationData]] = { DS3LocationData("RS: Homeward Bone - balcony by Farron Keep", "Homeward Bone x2"), DS3LocationData("RS: Titanite Shard - woods, surrounded by enemies", "Titanite Shard"), DS3LocationData("RS: Twin Dragon Greatshield - woods by Crucifixion Woods bonfire", - "Twin Dragon Greatshield"), + "Twin Dragon Greatshield", missable=True), # After Eclipse DS3LocationData("RS: Sorcerer Hood - water beneath stronghold", "Sorcerer Hood", hidden=True), # Hidden fall DS3LocationData("RS: Sorcerer Robe - water beneath stronghold", "Sorcerer Robe", @@ -1887,7 +1887,7 @@ location_tables: Dict[str, List[DS3LocationData]] = { DS3LocationData("AL: Twinkling Titanite - lizard after light cathedral #2", "Twinkling Titanite", lizard=True), DS3LocationData("AL: Aldrich's Ruby - dark cathedral, miniboss", "Aldrich's Ruby", - miniboss=True), # Deep Accursed drop + miniboss=True, missable=True), # Deep Accursed drop, missable after defeating Aldrich DS3LocationData("AL: Aldrich Faithful - water reserves, talk to McDonnel", "Aldrich Faithful", hidden=True), # Behind illusory wall diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 5e1003d2a9..6584ccec87 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -75,6 +75,13 @@ class DarkSouls3World(World): """The pool of all items within this particular world. This is a subset of `self.multiworld.itempool`.""" + missable_dupe_prog_locs: Set[str] = {"PC: Storm Ruler - Siegward", + "US: Pyromancy Flame - Cornyx", + "US: Tower Key - kill Irina"} + """Locations whose vanilla item is a missable duplicate of a non-missable progression item. + If vanilla, these locations shouldn't be expected progression, so they aren't created and don't get rules. + """ + def __init__(self, multiworld: MultiWorld, player: int): super().__init__(multiworld, player) self.all_excluded_locations = set() @@ -258,10 +265,7 @@ class DarkSouls3World(World): new_location.progress_type = LocationProgressType.EXCLUDED else: # Don't allow missable duplicates of progression items to be expected progression. - if location.name in {"PC: Storm Ruler - Siegward", - "US: Pyromancy Flame - Cornyx", - "US: Tower Key - kill Irina"}: - continue + if location.name in self.missable_dupe_prog_locs: continue # Replace non-randomized items with events that give the default item event_item = ( @@ -705,7 +709,7 @@ class DarkSouls3World(World): if self._is_location_available("US: Young White Branch - by white tree #2"): self._add_item_rule( "US: Young White Branch - by white tree #2", - lambda item: item.player == self.player and not item.data.unique + lambda item: item.player != self.player or not item.data.unique ) # Make sure the Storm Ruler is available BEFORE Yhorm the Giant @@ -1286,8 +1290,9 @@ class DarkSouls3World(World): data = location_dictionary[location] if data.dlc and not self.options.enable_dlc: continue if data.ngp and not self.options.enable_ngp: continue + # Don't add rules to missable duplicates of progression items + if location in self.missable_dupe_prog_locs and not self._is_location_available(location): continue - if not self._is_location_available(location): continue if isinstance(rule, str): assert item_dictionary[rule].classification == ItemClassification.progression rule = lambda state, item=rule: state.has(item, self.player) diff --git a/worlds/dark_souls_3/docs/setup_en.md b/worlds/dark_souls_3/docs/setup_en.md index 4c3a6b2a7d..7edf0d54e1 100644 --- a/worlds/dark_souls_3/docs/setup_en.md +++ b/worlds/dark_souls_3/docs/setup_en.md @@ -39,16 +39,14 @@ randomized item and (optionally) enemy locations. You only need to do this once To run _Dark Souls III_ in Archipelago mode: -1. Start Steam. **Do not run in offline mode.** Running Steam in offline mode will make certain - scripted invaders fail to spawn. Instead, change the game itself to offline mode on the menu - screen. +1. Start Steam. **Do not run Steam in offline mode.** Running Steam in offline mode will make certain + scripted invaders fail to spawn. -2. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that +2. To prevent you from getting penalized, **make sure to set _Dark Souls III_ to offline mode in the game options.** + +3. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that you can use to interact with the Archipelago server. -3. Type `/connect {SERVER_IP}:{SERVER_PORT} {SLOT_NAME}` into the command prompt, with the - appropriate values filled in. For example: `/connect archipelago.gg:24242 PlayerName`. - 4. Start playing as normal. An "Archipelago connected" message will appear onscreen once you have control of your character and the connection is established. @@ -73,7 +71,7 @@ things to keep in mind: * To run the game itself, just run `launchmod_darksouls3.bat` under Proton. -[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0 +[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/6.0 [WINE]: https://www.winehq.org/ ## Troubleshooting diff --git a/worlds/dkc3/Regions.py b/worlds/dkc3/Regions.py index 6e968dbe1e..c6c7dd362e 100644 --- a/worlds/dkc3/Regions.py +++ b/worlds/dkc3/Regions.py @@ -802,8 +802,10 @@ def connect_regions(world: World, level_list): for i in range(0, len(kremwood_forest_levels) - 1): connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[i]) - connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1], - lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player))) + connection = connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1], + lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player))) + world.multiworld.register_indirect_condition(world.get_location(LocationName.riverside_race_flag).parent_region, + connection) # Cotton-Top Cove Connections cotton_top_cove_levels = [ @@ -837,8 +839,11 @@ def connect_regions(world: World, level_list): connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region, lambda state: (state.has(ItemName.bowling_ball, world.player, 1))) else: - connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region, - lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player))) + connection = connect(world, world.player, names, LocationName.mekanos_region, + LocationName.sky_high_secret_region, + lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player))) + world.multiworld.register_indirect_condition(world.get_location(LocationName.bleaks_house).parent_region, + connection) # K3 Connections k3_levels = [ @@ -946,3 +951,4 @@ def connect(world: World, player: int, used_names: typing.Dict[str, int], source source_region.exits.append(connection) connection.connect(target_region) + return connection diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 550d92419b..5496885a74 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -30,7 +30,6 @@ class Group(enum.Enum): Deprecated = enum.auto() - @dataclass(frozen=True) class ItemData: code_without_offset: offset @@ -98,14 +97,15 @@ def create_trap_items(world, world_options: Options.DLCQuestOptions, trap_needed return traps -def create_items(world, world_options: Options.DLCQuestOptions, locations_count: int, excluded_items: list[str], random: Random): +def create_items(world, world_options: Options.DLCQuestOptions, locations_count: int, excluded_items: list[str], + random: Random): created_items = [] if world_options.campaign == Options.Campaign.option_basic or world_options.campaign == Options.Campaign.option_both: - create_items_basic(world_options, created_items, world, excluded_items) + create_items_campaign(world_options, created_items, world, excluded_items, Group.DLCQuest, 825, 250) if (world_options.campaign == Options.Campaign.option_live_freemium_or_die or world_options.campaign == Options.Campaign.option_both): - create_items_lfod(world_options, created_items, world, excluded_items) + create_items_campaign(world_options, created_items, world, excluded_items, Group.Freemium, 889, 200) trap_items = create_trap_items(world, world_options, locations_count - len(created_items), random) created_items += trap_items @@ -113,27 +113,8 @@ def create_items(world, world_options: Options.DLCQuestOptions, locations_count: return created_items -def create_items_lfod(world_options, created_items, world, excluded_items): - for item in items_by_group[Group.Freemium]: - if item.name in excluded_items: - excluded_items.remove(item) - continue - - if item.has_any_group(Group.DLC): - created_items.append(world.create_item(item)) - if item.has_any_group(Group.Item) and world_options.item_shuffle == Options.ItemShuffle.option_shuffled: - created_items.append(world.create_item(item)) - if item.has_any_group(Group.Twice): - created_items.append(world.create_item(item)) - if world_options.coinsanity == Options.CoinSanity.option_coin: - if world_options.coinbundlequantity == -1: - create_coin_piece(created_items, world, 889, 200, Group.Freemium) - return - create_coin(world_options, created_items, world, 889, 200, Group.Freemium) - - -def create_items_basic(world_options, created_items, world, excluded_items): - for item in items_by_group[Group.DLCQuest]: +def create_items_campaign(world_options: Options.DLCQuestOptions, created_items: list[DLCQuestItem], world, excluded_items: list[str], group: Group, total_coins: int, required_coins: int): + for item in items_by_group[group]: if item.name in excluded_items: excluded_items.remove(item.name) continue @@ -146,14 +127,15 @@ def create_items_basic(world_options, created_items, world, excluded_items): created_items.append(world.create_item(item)) if world_options.coinsanity == Options.CoinSanity.option_coin: if world_options.coinbundlequantity == -1: - create_coin_piece(created_items, world, 825, 250, Group.DLCQuest) + create_coin_piece(created_items, world, total_coins, required_coins, group) return - create_coin(world_options, created_items, world, 825, 250, Group.DLCQuest) + create_coin(world_options, created_items, world, total_coins, required_coins, group) def create_coin(world_options, created_items, world, total_coins, required_coins, group): coin_bundle_required = math.ceil(required_coins / world_options.coinbundlequantity) - coin_bundle_useful = math.ceil((total_coins - coin_bundle_required * world_options.coinbundlequantity) / world_options.coinbundlequantity) + coin_bundle_useful = math.ceil( + (total_coins - coin_bundle_required * world_options.coinbundlequantity) / world_options.coinbundlequantity) for item in items_by_group[group]: if item.has_any_group(Group.Coin): for i in range(coin_bundle_required): @@ -165,7 +147,7 @@ def create_coin(world_options, created_items, world, total_coins, required_coins def create_coin_piece(created_items, world, total_coins, required_coins, group): for item in items_by_group[group]: if item.has_any_group(Group.Piece): - for i in range(required_coins*10): + for i in range(required_coins * 10): created_items.append(world.create_item(item)) for i in range((total_coins - required_coins) * 10): created_items.append(world.create_item(item, ItemClassification.useful)) diff --git a/worlds/dlcquest/Rules.py b/worlds/dlcquest/Rules.py index 3461d0633e..5dfd80165a 100644 --- a/worlds/dlcquest/Rules.py +++ b/worlds/dlcquest/Rules.py @@ -280,16 +280,19 @@ def set_boss_door_requirements_rules(player, world): set_rule(world.get_entrance("Boss Door", player), has_3_swords) -def set_lfod_self_obtained_items_rules(world_options, player, world): +def set_lfod_self_obtained_items_rules(world_options, player, multiworld): if world_options.item_shuffle != Options.ItemShuffle.option_disabled: return - set_rule(world.get_entrance("Vines", player), + world = multiworld.worlds[player] + set_rule(world.get_entrance("Vines"), lambda state: state.has("Incredibly Important Pack", player)) - set_rule(world.get_entrance("Behind Rocks", player), + set_rule(world.get_entrance("Behind Rocks"), lambda state: state.can_reach("Cut Content", 'region', player)) - set_rule(world.get_entrance("Pickaxe Hard Cave", player), + multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Behind Rocks")) + set_rule(world.get_entrance("Pickaxe Hard Cave"), lambda state: state.can_reach("Cut Content", 'region', player) and state.has("Name Change Pack", player)) + multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Pickaxe Hard Cave")) def set_lfod_shuffled_items_rules(world_options, player, world): diff --git a/worlds/factorio/Client.py b/worlds/factorio/Client.py index 199cb29b86..d7992c3276 100644 --- a/worlds/factorio/Client.py +++ b/worlds/factorio/Client.py @@ -69,7 +69,9 @@ class FactorioContext(CommonContext): # updated by spinup server mod_version: Version = Version(0, 0, 0) - def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool): + def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool, + rcon_port: int, rcon_password: str, server_settings_path: str | None, + factorio_server_args: tuple[str, ...]): super(FactorioContext, self).__init__(server_address, password) self.send_index: int = 0 self.rcon_client = None @@ -82,6 +84,10 @@ class FactorioContext(CommonContext): self.filter_item_sends: bool = filter_item_sends self.multiplayer: bool = False # whether multiple different players have connected self.bridge_chat_out: bool = bridge_chat_out + self.rcon_port: int = rcon_port + self.rcon_password: str = rcon_password + self.server_settings_path: str = server_settings_path + self.additional_factorio_server_args = factorio_server_args @property def energylink_key(self) -> str: @@ -126,6 +132,18 @@ class FactorioContext(CommonContext): self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " f"{text}") + @property + def server_args(self) -> tuple[str, ...]: + if self.server_settings_path: + return ( + "--rcon-port", str(self.rcon_port), + "--rcon-password", self.rcon_password, + "--server-settings", self.server_settings_path, + *self.additional_factorio_server_args) + else: + return ("--rcon-port", str(self.rcon_port), "--rcon-password", self.rcon_password, + *self.additional_factorio_server_args) + @property def energy_link_status(self) -> str: if not self.energy_link_increment: @@ -311,7 +329,7 @@ async def factorio_server_watcher(ctx: FactorioContext): executable, "--create", savegame_name, "--preset", "archipelago" )) factorio_process = subprocess.Popen((executable, "--start-server", savegame_name, - *(str(elem) for elem in server_args)), + *ctx.server_args), stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, @@ -331,7 +349,7 @@ async def factorio_server_watcher(ctx: FactorioContext): factorio_queue.task_done() if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg: - ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password, + ctx.rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password, timeout=5) if not ctx.server: logger.info("Established bridge to Factorio Server. " @@ -422,7 +440,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: executable, "--create", savegame_name )) factorio_process = subprocess.Popen( - (executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)), + (executable, "--start-server", savegame_name, *ctx.server_args), stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, @@ -451,7 +469,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: "or a Factorio sharing data directories is already running. " "Server could not start up.") if not rcon_client and "Starting RCON interface at IP ADDR:" in msg: - rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) + rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password) if ctx.mod_version == ctx.__class__.mod_version: raise Exception("No Archipelago mod was loaded. Aborting.") await get_info(ctx, rcon_client) @@ -474,9 +492,8 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: return False -async def main(args, filter_item_sends: bool, filter_bridge_chat_out: bool): - ctx = FactorioContext(args.connect, args.password, filter_item_sends, filter_bridge_chat_out) - +async def main(make_context): + ctx = make_context() ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") if gui_enabled: @@ -509,38 +526,42 @@ class FactorioJSONtoTextParser(JSONtoTextParser): return self._handle_text(node) -parser = get_base_parser(description="Optional arguments to FactorioClient follow. " - "Remaining arguments get passed into bound Factorio instance." - "Refer to Factorio --help for those.") -parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') -parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') -parser.add_argument('--server-settings', help='Factorio server settings configuration file.') - -args, rest = parser.parse_known_args() -rcon_port = args.rcon_port -rcon_password = args.rcon_password if args.rcon_password else ''.join( - random.choice(string.ascii_letters) for x in range(32)) factorio_server_logger = logging.getLogger("FactorioServer") settings: FactorioSettings = get_settings().factorio_options if os.path.samefile(settings.executable, sys.executable): selected_executable = settings.executable settings.executable = FactorioSettings.executable # reset to default - raise Exception(f"FactorioClient was set to run itself {selected_executable}, aborting process bomb.") + raise Exception(f"Factorio Client was set to run itself {selected_executable}, aborting process bomb.") executable = settings.executable -server_settings = args.server_settings if args.server_settings \ - else getattr(settings, "server_settings", None) -server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password) - -def launch(): +def launch(*new_args: str): import colorama - global executable, server_settings, server_args + global executable colorama.just_fix_windows_console() + # args handling + parser = get_base_parser(description="Optional arguments to Factorio Client follow. " + "Remaining arguments get passed into bound Factorio instance." + "Refer to Factorio --help for those.") + parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') + parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') + parser.add_argument('--server-settings', help='Factorio server settings configuration file.') + + args, rest = parser.parse_known_args(args=new_args) + rcon_port = args.rcon_port + rcon_password = args.rcon_password if args.rcon_password else ''.join( + random.choice(string.ascii_letters) for _ in range(32)) + + server_settings = args.server_settings if args.server_settings \ + else getattr(settings, "server_settings", None) + if server_settings: server_settings = os.path.abspath(server_settings) + if not os.path.isfile(server_settings): + raise FileNotFoundError(f"Could not find file {server_settings} for server_settings. Aborting.") + initial_filter_item_sends = bool(settings.filter_item_sends) initial_bridge_chat_out = bool(settings.bridge_chat_out) @@ -554,14 +575,9 @@ def launch(): else: raise FileNotFoundError(f"Path {executable} is not an executable file.") - if server_settings and os.path.isfile(server_settings): - server_args = ( - "--rcon-port", rcon_port, - "--rcon-password", rcon_password, - "--server-settings", server_settings, - *rest) - else: - server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest) - - asyncio.run(main(args, initial_filter_item_sends, initial_bridge_chat_out)) + asyncio.run(main(lambda: FactorioContext( + args.connect, args.password, + initial_filter_item_sends, initial_bridge_chat_out, + rcon_port, rcon_password, server_settings, rest + ))) colorama.deinit() diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index eb305897f4..3cc156112d 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -67,6 +67,7 @@ class FactorioModFile(worlds.Files.APPlayerContainer): game = "Factorio" compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]] + patch_file_ending = ".zip" def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 12fc90c1fd..0a789669d5 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -321,7 +321,7 @@ class InventorySpillTrapCount(TrapCount): class FactorioWorldGen(OptionDict): """World Generation settings. Overview of options at https://wiki.factorio.com/Map_generator, - with in-depth documentation at https://lua-api.factorio.com/latest/Concepts.html#MapGenSettings""" + with in-depth documentation at https://lua-api.factorio.com/latest/concepts/MapGenSettings.html""" display_name = "World Generation" # FIXME: do we want default be a rando-optimized default or in-game DS? value: dict[str, dict[str, typing.Any]] diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index bfa6ceb894..8dc654099b 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -22,9 +22,9 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table from .settings import FactorioSettings -def launch_client(): +def launch_client(*args: str): from .Client import launch - launch_component(launch, name="FactorioClient") + launch_component(launch, name="Factorio Client", args=args) components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT)) diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 6f0520febc..db93981bc9 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -288,7 +288,7 @@ world and the beginning of another world. You can also combine multiple files by ### Example ```yaml -description: Example of generating multiple worlds. World 1 of 3 +description: Example of generating multiple worlds. World 1 of 2 name: Mario game: Super Mario 64 requires: @@ -310,31 +310,6 @@ Super Mario 64: --- -description: Example of generating multiple worlds. World 2 of 3 -name: Minecraft -game: Minecraft -Minecraft: - progression_balancing: 50 - accessibility: items - advancement_goal: 40 - combat_difficulty: hard - include_hard_advancements: false - include_unreasonable_advancements: false - include_postgame_advancements: false - shuffle_structures: true - structure_compasses: true - send_defeated_mobs: true - bee_traps: 15 - egg_shards_required: 7 - egg_shards_available: 10 - required_bosses: - none: 0 - ender_dragon: 1 - wither: 0 - both: 0 - ---- - description: Example of generating multiple worlds. World 2 of 2 name: ExampleFinder game: ChecksFinder @@ -344,6 +319,6 @@ ChecksFinder: accessibility: items ``` -The above example will generate 3 worlds - one Super Mario 64, one Minecraft, and one ChecksFinder. +The above example will generate 2 worlds - one Super Mario 64 and one ChecksFinder. diff --git a/worlds/generic/docs/plando_en.md b/worlds/generic/docs/plando_en.md index 9469624762..69f59c739e 100644 --- a/worlds/generic/docs/plando_en.md +++ b/worlds/generic/docs/plando_en.md @@ -27,73 +27,176 @@ requires: plando: bosses, items, texts, connections ``` +For a basic understanding of YAML files, refer to +[YAML Formatting](/tutorial/Archipelago/advanced_settings/en#yaml-formatting) +in Advanced Settings. + ## Item Plando -Item plando allows a player to place an item in a specific location or specific locations, or place multiple items into a -list of specific locations both in their own game or in another player's game. -* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, and either `item` and - `location`, or `items` and `locations`. - * `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or - false and defaults to true if omitted. - * `world` is the target world to place the item in. - * It gets ignored if only one world is generated. - * Can be a number, name, true, false, null, or a list. False is the default. - * If a number is used, it targets that slot or player number in the multiworld. - * If a name is used, it will target the world with that player name. - * If set to true, it will be any player's world besides your own. - * If set to false, it will target your own world. - * If set to null, it will target a random world in the multiworld. - * If a list of names is used, it will target the games with the player names specified. - * `force` determines whether the generator will fail if the item can't be placed in the location. Can be true, false, - or silent. Silent is the default. - * If set to true, the item must be placed and the generator will throw an error if it is unable to do so. - * If set to false, the generator will log a warning if the placement can't be done but will still generate. - * If set to silent and the placement fails, it will be ignored entirely. - * `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and - if omitted will default to 100. - * Single Placement is when you use a plando block to place a single item at a single location. - * `item` is the item you would like to place and `location` is the location to place it. - * Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted. - * `items` defines the items to use, each with a number for the amount. Using `true` instead of a number uses however many of that item are in your item pool. - * `locations` is a list of possible locations those items can be placed in. - * Some special location group names can be specified: - * `early_locations` will add all sphere 1 locations (locations logically reachable only with your starting inventory) - * `non_early_locations` will add all locations beyond sphere 1 (locations that require finding at least one item before they become logically reachable) - * Using the multi placement method, placements are picked randomly. +Item Plando allows a player to place an item in a specific location or locations, or place multiple items into a list +of specific locations in their own game and/or in another player's game. - * `count` can be used to set the maximum number of items placed from the block. The default is 1 if using `item` and False if using `items` - * If a number is used, it will try to place this number of items. - * If set to false, it will try to place as many items from the block as it can. - * If `min` and `max` are defined, it will try to place a number of items between these two numbers at random. +To add item plando to your player yaml, you add them under the `plando_items` block. You should start with `item` if you +want to do Single Placement, or `items` if you want to do Multi Placement. A list of items can still be defined under +`item` but only one of them will be chosen at random to be used. +After you define `item/items`, you would define `location` or `locations`, depending on if you want to fill one +location or many. Note that both `location` and `locations` are optional. A list of locations can still be defined under +`location` but only one of them will be chosen at random to be used. + +You may do any combination of `item/items` and `location/locations` in a plando block, but the block only places items +in locations **until the shorter of the two lists is used up.** + +Once you are satisfied with your first block, you may continue to define ones under the same `plando_items` parent. +Each block can have several different options to tailor it the way you like. + +* The `items` section defines the items to use. Each item name can be followed by a colon and a value. + * A numerical value indicates the amount of that item. + * A `true` value uses all copies of that item that are in your item pool. + +* The `item` section defines a list of items to use, from which one will be chosen at random. Each item name can be + followed by a colon and a value. The value indicates the weight of that item being chosen. + +* The `locations` section defines possible locations those items can be placed in. Two special location groups exist: + * `early_locations` will add all sphere 1 locations (locations logically reachable only with your starting + inventory). + * `non_early_locations` will add all locations beyond sphere 1 (locations that require finding at least one item + before they become logically reachable). + +* `from_pool` determines if the item should be taken *from* the item pool or *created* from scratch. + * `false`: Create a new item with the same name (the world will determine its properties e.g. classification). + * `true`: Take the existing item, if it exists, from the item pool. If it does not exist, one will be created from + scratch. **(Default)** + +* `world` is the target world to place the item in. It gets ignored if only one world is generated. + * **A number:** Use this slot or player number in the multiworld. + * **A name:** Use the world with that player name. + * **A list of names:** Use the worlds with the player names specified. + * `true`: Locations will be in any player's world besides your own. + * `false`: Locations will be in your own world. **(Default)** + * `null`: Locations will be in a random world in the multiworld. + +* `force` determines whether the generator will fail if the plando block cannot be fulfilled. + * `true`: The generator will throw an error if it is unable to place an item. + * `false`: The generator will log a warning if it is unable to place an item, but it will still generate. + * `silent`: If the placement fails, it will be ignored entirely. **(Default)** + +* `percentage` is the percentage chance for the block to trigger. This can be any integer from 0 to 100. + **(Default: 100)** + +* `count` sets the number of items placed from the list. + * **Default: 1 if using `item` or `location`, and `false` otherwise.** + * **A number:** It will place this number of items. + * `false`: It will place as many items from the list as it can. + * **If `min` is defined,** it will place at least `min` many items (can be combined with `max`). + * **If `max` is defined,** it will place at most `max` many items (can be combined with `min`). ### Available Items and Locations -A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is case-sensitive. +A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and +locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. Names are +case-sensitive. You can also use item groups and location groups that are defined in the datapackage. -### Examples +## Item Plando Examples +```yaml + plando_items: + # Example block - Pokémon Red and Blue + - items: + Potion: 3 + locations: + - "Route 1 - Free Sample Man" + - "Mt Moon 1F - West Item" + - "Mt Moon 1F - South Item" +``` +This block will lock 3 Potion items on the Route 1 Pokémart employee and 2 Mt Moon items. Note these are all +Potions in the vanilla game. The world value has not been specified, so these locations must be in this player's own +world by default. ```yaml plando_items: - # example block 1 - Timespinner + # Example block - A Link to the Past + - items: + Progressive Sword: 4 + world: + - BobsWitness + - BobsRogueLegacy + count: + min: 1 + max: 4 +``` +This block will attempt to place a random number, between 1 and 4, of Progressive Swords into any locations within the +game slots named "BobsWitness" and "BobsRogueLegacy." + +```yaml + plando_items: + # Example block - Secret of Evermore + - items: + Levitate: 1 + Revealer: 1 + Energize: 1 + locations: + - Master Sword Pedestal + - Desert Discard + world: true + count: 2 +``` +This block will choose 2 from the Levitate, Revealer, and Energize items at random and attempt to put them into the +locations named "Master Sword Pedestal" and "Desert Discard". Because the world value is `true`, these locations +must be in other players' worlds. + +```yaml + plando_items: + # Example block - Timespinner - item: Empire Orb: 1 - Radiant Orb: 1 + Radiant Orb: 3 location: Starter Chest 1 - from_pool: true + from_pool: false world: true percentage: 50 - - # example block 2 - Ocarina of Time +``` +This block will place a single item, either the Empire Orb or Radiant Orb, on the location "Starter Chest 1". There is +a 25% chance it is Empire Orb, and 75% chance it is Radiant Orb (1 to 3 odds). The world value is `true`, so this +location must be in another player's world. Because the from_pool value is `false`, a copy of these items is added to +these locations, while the originals remain in the item pool to be shuffled. Unlike the previous examples, which will +always trigger, this block only has a 50% chance to trigger. + +```yaml + plando_items: + # Example block - Factorio + - items: + progressive-electric-energy-distribution: 2 + electric-energy-accumulators: 1 + progressive-turret: 2 + locations: + - AP-1-001 + - AP-1-002 + - AP-1-003 + - AP-1-004 + percentage: 80 + force: true + from_pool: true + world: false +``` +This block lists 5 items but only 4 locations, so it will place all but 1 of the items randomly among the locations +chosen here. This block has an 80% chance of occurring. Because force is `true`, the Generator will fail if it cannot +place one of the selected items (not including the fifth item). From_pool and World have been set to their default +values here, but they can be omitted and have the same result: items will be removed from the pool, and the locations +are in this player's own world. + +**NOTE:** Factorio's locations are dynamically generated, so the locations listed above may not exist in your game, +they are here for demonstration only. + +```yaml + plando_items: + # Example block - Ocarina of Time - items: - Kokiri Sword: 1 Biggoron Sword: 1 Bow: 1 Magic Meter: 1 Progressive Strength Upgrade: 3 Progressive Hookshot: 2 locations: - - Deku Tree Slingshot Chest - Dodongos Cavern Bomb Bag Chest - Jabu Jabus Belly Boomerang Chest - Bottom of the Well Lens of Truth Chest @@ -102,53 +205,16 @@ A list of all available items and locations can be found in the [website's datap - Water Temple Longshot Chest - Shadow Temple Hover Boots Chest - Spirit Temple Silver Gauntlets Chest - world: false - - # example block 3 - Factorio - - items: - progressive-electric-energy-distribution: 2 - electric-energy-accumulators: 1 - progressive-turret: 2 - locations: - - military - - gun-turret - - logistic-science-pack - - steel-processing - percentage: 80 - force: true - - # example block 4 - Secret of Evermore - - items: - Levitate: 1 - Revealer: 1 - Energize: 1 - locations: - - Master Sword Pedestal - - Boss Relic 1 - world: true - count: 2 - - # example block 5 - A Link to the Past - - items: - Progressive Sword: 4 - world: - - BobsSlaytheSpire - - BobsRogueLegacy - count: - min: 1 - max: 4 + from_pool: false + + - item: Kokiri Sword + location: Deku Tree Slingshot Chest + from_pool: false ``` -1. This block has a 50% chance to occur, and if it does, it will place either the Empire Orb or Radiant Orb on another -player's Starter Chest 1 and removes the chosen item from the item pool. -2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots -in their own dungeon major item chests. -3. This block has an 80% chance of occurring, and when it does, it will place all but 1 of the items randomly among the -four locations chosen here. -4. This block will always trigger and will attempt to place a random 2 of Levitate, Revealer and Energize into -other players' Master Sword Pedestals or Boss Relic 1 locations. -5. This block will always trigger and will attempt to place a random number, between 1 and 4, of progressive swords -into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy. - +The first block will place the player's Biggoron Sword, Bow, Magic Meter, strength upgrades, and hookshots in the +dungeon major item chests. Because the from_pool value is `false`, a copy of these items is added to these locations, +while the originals remain in the item pool to be shuffled. The second block will place the Kokiri Sword in the Deku +Tree Slingshot Chest, again not from the pool. ## Boss Plando @@ -194,7 +260,7 @@ relevant guide: [A Link to the Past Plando Guide](/tutorial/A%20Link%20to%20the% ## Connection Plando -This is currently only supported by a few games, including A Link to the Past, Minecraft, and Ocarina of Time. As the way that these games interact with their +This is currently only supported by a few games, including A Link to the Past and Ocarina of Time. As the way that these games interact with their connections is different, only the basics are explained here. More specific information for connection plando in A Link to the Past can be found in its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections). @@ -207,7 +273,6 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections). [A Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852) -[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/data/regions.json#L18****) ### Examples @@ -223,19 +288,10 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections). - entrance: Agahnims Tower exit: Old Man Cave Exit (West) direction: exit - - # example block 2 - Minecraft - - entrance: Overworld Structure 1 - exit: Nether Fortress - direction: both - - entrance: Overworld Structure 2 - exit: Village - direction: both + ``` 1. These connections are decoupled, so going into the Lake Hylia Cave Shop will take you to the inside of Cave 45, and when you leave the interior, you will exit to the Cave 45 ledge. Going into the Cave 45 entrance will then take you to the Lake Hylia Cave Shop. Walking into the entrance for the Old Man Cave and Agahnim's Tower entrance will both take you to their locations as normal, but leaving Old Man Cave will exit at Agahnim's Tower. -2. This will force a Nether fortress and a village to be the Overworld structures for your game. Note that for the - Minecraft connection plando to work structure shuffle must be enabled. diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index 4a0da109fa..317d29334b 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -154,7 +154,17 @@ class HKWeb(WebWorld): ["JoaoVictor-FA"] ) - tutorials = [setup_en, setup_pt_br] + setup_es = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "Español", + "setup_es.md", + "setup/es", + ["GreenMarco", "Panto UwUr"] + ) + + tutorials = [setup_en, setup_pt_br, setup_es] + game_info_languages = ["en", "es"] bug_report_page = "https://github.com/Ijwu/Archipelago.HollowKnight/issues/new?assignees=&labels=bug%2C+needs+investigation&template=bug_report.md&title=" @@ -218,6 +228,11 @@ class HKWorld(World): wp = self.options.WhitePalace if wp <= WhitePalace.option_nopathofpain: exclusions.update(path_of_pain_locations) + exclusions.update(( + "Soul_Totem-Path_of_Pain", + "Lore_Tablet-Path_of_Pain_Entrance", + "Journal_Entry-Seal_of_Binding", + )) if wp <= WhitePalace.option_kingfragment: exclusions.update(white_palace_checks) if wp == WhitePalace.option_exclude: @@ -226,6 +241,9 @@ class HKWorld(World): # If charms are randomized, this will be junk-filled -- so transitions and events are not progression exclusions.update(white_palace_transitions) exclusions.update(white_palace_events) + exclusions.update(item_name_groups["PalaceJournal"]) + exclusions.update(item_name_groups["PalaceLore"]) + exclusions.update(item_name_groups["PalaceTotem"]) return exclusions def create_regions(self): diff --git a/worlds/hk/docs/es_Hollow Knight.md b/worlds/hk/docs/es_Hollow Knight.md new file mode 100644 index 0000000000..1a086086ad --- /dev/null +++ b/worlds/hk/docs/es_Hollow Knight.md @@ -0,0 +1,25 @@ +# Hollow Knight + +## ¿Dónde está la página de opciones? + +La [página de opciones de jugador para este juego](../player-options) contiene todas las opciones que necesitas para +configurar y exportar un archivo de configuración. + +## ¿Qué se randomiza en este juego? + +El randomizer cambia la ubicación de los objetos. Los objetos que se intercambian se eligen dentro de tu YAML. +Los costes de las tiendas son aleatorios. Los objetos que podrían ser aleatorios, pero no lo son, permanecerán sin +modificar en sus ubicaciones habituales. En particular, cuando los ítems con el PadreLarva y la Vidente están +parcialmente randomizados, los ítems randomizados se obtendrán de un cofre en la habitación, mientras que los ítems no +randomizados serán dados por el NPC de forma normal. + +## ¿Qué objetos de Hollow Knight pueden aparecer en los mundos de otros jugadores? + +Esto depende enteramente de tus opciones YAML. Algunos ejemplos son: amuletos, larvas, capullos de saviavida, geo, etc. + +## ¿Qué aspecto tienen los objetos de otro mundo en Hollow Knight? + +Cuando el jugador de Hollow Knight recoja un objeto de un lugar y sea un objeto para otro juego, aparecerá en la +pantalla de objetos recientes de ese jugador como un objeto enviado a otro jugador. Si el objeto es para otro jugador +de Hollow Knight entonces el sprite será el del sprite original del objeto. Si el objeto pertenece a un jugador que no +está jugando a Hollow Knight, el sprite será el logo del Archipiélago. \ No newline at end of file diff --git a/worlds/hk/docs/setup_es.md b/worlds/hk/docs/setup_es.md new file mode 100644 index 0000000000..13628c4019 --- /dev/null +++ b/worlds/hk/docs/setup_es.md @@ -0,0 +1,64 @@ +# Hollow Knight Archipelago + +## Software requerido +* Descarga y descomprime Lumafly Mod manager desde el [sitio web de Lumafly](https://themulhima.github.io/Lumafly/) +* Tener una copia legal de Hollow Knight. + * Las versiones de Steam, GOG y Xbox Game Pass son compatibles + * Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles + +## Instalación del mod de Archipelago con Lumafly +1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight +2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes: + * Haz clic en uno de los enlaces de abajo para permitir Lumafly para instalar los mods. Lumafly pedirá + confirmación. + * [Archipiélago y dependencias solamente](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago) + * [Archipelago con rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/) + (incluye Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn, + y AdditionalMaps). + * Haz clic en el botón "Instalar" situado junto a la entrada del mod "Archipiélago". Si lo deseas, instala también + "Archipelago Map Mod" para utilizarlo como rastreador en el juego. + Si lo requieres (Y recomiendo hacerlo) busca e instala Archipelago Map Mod para usar un tracker in-game +3. Ejecuta el juego desde el apartado de inicio haciendo click en el botón Launch with Mods + +## Que hago si Lumafly no encontro la ruta de instalación de mi juego? +1. Busca el directorio manualmente + * En Xbox Game pass: + 1. Entra a la Xbox App y dirigete sobre el icono de Hollow Knight que esta a la izquierda. + 2. Haz click en los 3 puntitos y elige el apartado Administrar + 3. Dirigete al apartado Archivos Locales y haz click en Buscar + 4. Abre en Hollow Knight, luego Content y copia la ruta de archivos que esta en la barra de navegación. + * En Steam: + 1. Si instalaste Hollow Knight en algún otro disco que no sea el predeterminado, ya sabrás donde se encuentra + el juego, ve a esa carpeta, abrela y copia la ruta de archivos que se encuentra en la barra de navegación. + * En Windows, la ruta predeterminada suele ser:`C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight` + * En linux/Steam Deck suele ser: ~/.local/share/Steam/steamapps/common/Hollow Knight + * En Mac suele ser: ~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app +2. Ejecuta Lumafly como administrador y, cuando te pregunte por la ruta de instalación, pega la ruta que copeaste + anteriormente. + +## Configuración de tu fichero YAML +### ¿Qué es un YAML y por qué necesito uno? +Un archivo YAML es la forma en la que proporcionas tus opciones de jugador a Archipelago. +Mira la [guía básica de configuración multiworld](/tutorial/Archipelago/setup/en) aquí en la web de Archipelago para +aprender más, (solo se encuentra en Inglés). + +### ¿Dónde consigo un YAML? +Puedes usar la [página de opciones de juego para Hollow Knight](/games/Hollow%20Knight/player-options) aquí en la web +de Archipelago para generar un YAML usando una interfaz gráfica. + +## Unete a una partida de Archipelago en Hollow Knight +1. Inicia el juego con los mods necesarios indicados anteriormente. +2. Crea una **nueva partida.** +3. Elige el modo **Archipelago** en la selección de modos de partida. +4. Introduce la configuración correcta para tu servidor de Archipelago. +5. Pulsa **Iniciar** para iniciar la partida. El juego se quedará con la pantalla en negro unos segundos mientras + coloca todos los objetos. +6. El juego debera comenzar y ya estaras dentro del servidor. + * Si estas esperando a que termine un contador/timer, procura presionar el boton Start cuando el contador/timer + termine. + * Otra manera es pausar el juego y esperar a que el contador/timer termine cuando ingreses a la partida. + +## Consejos y otros comandos +Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la +[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto, +que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). \ No newline at end of file diff --git a/worlds/hylics2/Options.py b/worlds/hylics2/Options.py index db9c316a7b..51072edcbd 100644 --- a/worlds/hylics2/Options.py +++ b/worlds/hylics2/Options.py @@ -57,13 +57,8 @@ class ExtraLogic(DefaultOnToggle): class Hylics2DeathLink(DeathLink): - """ - When you die, everyone dies. The reverse is also true. - - Note that this also includes death by using the PERISH gesture. - - Can be toggled via in-game console command "/deathlink". - """ + __doc__ = (DeathLink.__doc__ + "\n\n Note that this also includes death by using the PERISH gesture." + + "\n\n Can be toggled via in-game console command \"/deathlink\".") @dataclass diff --git a/worlds/jakanddaxter/__init__.py b/worlds/jakanddaxter/__init__.py index d508e967ae..9a2cb30293 100644 --- a/worlds/jakanddaxter/__init__.py +++ b/worlds/jakanddaxter/__init__.py @@ -34,9 +34,9 @@ from .locations import (JakAndDaxterLocation, cache_location_table, orb_location_table) from .regions import create_regions -from .rules import (enforce_multiplayer_limits, - enforce_singleplayer_limits, - verify_orb_trade_amounts, +from .rules import (enforce_mp_absolute_limits, + enforce_mp_friendly_limits, + enforce_sp_limits, set_orb_trade_rule) from .locs import (cell_locations as cells, scout_locations as scouts, @@ -258,18 +258,31 @@ class JakAndDaxterWorld(World): self.options.mountain_pass_cell_count.value = self.power_cell_thresholds[1] self.options.lava_tube_cell_count.value = self.power_cell_thresholds[2] - # Store this for remove function. - self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds] - - # For the fairness of other players in a multiworld game, enforce some friendly limitations on our options, - # so we don't cause chaos during seed generation. These friendly limits should **guarantee** a successful gen. - # We would have done this earlier, but we needed to sort the power cell thresholds first. + # We would have done this earlier, but we needed to sort the power cell thresholds first. Don't worry, we'll + # come back to them. enforce_friendly_options = self.settings.enforce_friendly_options - if enforce_friendly_options: - if self.multiworld.players > 1: - enforce_multiplayer_limits(self) + if self.multiworld.players == 1: + # For singleplayer games, always enforce/clamp the cell counts to valid values. + enforce_sp_limits(self) + else: + if enforce_friendly_options: + # For multiplayer games, we have a host setting to make options fair/sane for other players. + # If this setting is enabled, enforce/clamp some friendly limitations on our options. + enforce_mp_friendly_limits(self) else: - enforce_singleplayer_limits(self) + # Even if the setting is disabled, some values must be clamped to avoid generation errors. + enforce_mp_absolute_limits(self) + + # That's right, set the collection of thresholds again. Don't just clamp the values without updating this list! + self.power_cell_thresholds = [ + self.options.fire_canyon_cell_count.value, + self.options.mountain_pass_cell_count.value, + self.options.lava_tube_cell_count.value, + 100, # The 100 Power Cell Door. + ] + + # Now that the threshold list is finalized, store this for the remove function. + self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds] # Calculate the number of power cells needed for full region access, the number being replaced by traps, # and the number of remaining filler. @@ -282,11 +295,6 @@ class JakAndDaxterWorld(World): self.options.filler_power_cells_replaced_with_traps.value = self.total_trap_cells self.total_filler_cells = non_prog_cells - self.total_trap_cells - # Verify that we didn't overload the trade amounts with more orbs than exist in the world. - # This is easy to do by accident even in a singleplayer world. - self.total_trade_orbs = (9 * self.options.citizen_orb_trade_amount) + (6 * self.options.oracle_orb_trade_amount) - verify_orb_trade_amounts(self) - # Cache the orb bundle size and item name for quicker reference. if self.options.enable_orbsanity == options.EnableOrbsanity.option_per_level: self.orb_bundle_size = self.options.level_orbsanity_bundle_size.value diff --git a/worlds/jakanddaxter/client.py b/worlds/jakanddaxter/client.py index 2b669d3847..90e6a42aa7 100644 --- a/worlds/jakanddaxter/client.py +++ b/worlds/jakanddaxter/client.py @@ -367,7 +367,7 @@ def find_root_directory(ctx: JakAndDaxterContext): f" Close all launchers, games, clients, and console windows, then restart Archipelago.") if not os.path.exists(settings_path): - msg = (f"{err_title}: the OpenGOAL settings file does not exist.\n" + msg = (f"{err_title}: The OpenGOAL settings file does not exist.\n" f"{alt_instructions}") ctx.on_log_error(logger, msg) return @@ -375,14 +375,44 @@ def find_root_directory(ctx: JakAndDaxterContext): with open(settings_path, "r") as f: load = json.load(f) - jak1_installed = load["games"]["Jak 1"]["isInstalled"] + # This settings file has changed format once before, and may do so again in the future. + # Guard against future incompatibilities by checking the file version first, and use that to determine + # what JSON keys to look for next. + try: + settings_version = load["version"] + logger.debug(f"OpenGOAL settings file version: {settings_version}") + except KeyError: + msg = (f"{err_title}: The OpenGOAL settings file has no version number!\n" + f"{alt_instructions}") + ctx.on_log_error(logger, msg) + return + + try: + if settings_version == "2.0": + jak1_installed = load["games"]["Jak 1"]["isInstalled"] + mod_sources = load["games"]["Jak 1"]["modsInstalledVersion"] + + elif settings_version == "3.0": + jak1_installed = load["games"]["jak1"]["isInstalled"] + mod_sources = load["games"]["jak1"]["mods"] + + else: + msg = (f"{err_title}: The OpenGOAL settings file has an unknown version number ({settings_version}).\n" + f"{alt_instructions}") + ctx.on_log_error(logger, msg) + return + except KeyError as e: + msg = (f"{err_title}: The OpenGOAL settings file does not contain key entry {e}!\n" + f"{alt_instructions}") + ctx.on_log_error(logger, msg) + return + if not jak1_installed: msg = (f"{err_title}: The OpenGOAL Launcher is missing a normal install of Jak 1!\n" f"{alt_instructions}") ctx.on_log_error(logger, msg) return - mod_sources = load["games"]["Jak 1"]["modsInstalledVersion"] if mod_sources is None: msg = (f"{err_title}: No mod sources have been configured in the OpenGOAL Launcher!\n" f"{alt_instructions}") diff --git a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md index 6cf8ae54a5..77fbd514cb 100644 --- a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md +++ b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md @@ -18,7 +18,7 @@ - [What do Traps do?](#what-do-traps-do) - [What kind of Traps are there?](#what-kind-of-traps-are-there) - [I got soft-locked and cannot leave, how do I get out of here?](#i-got-soft-locked-and-cannot-leave-how-do-i-get-out-of-here) -- [Why did I get an Option Error when generating a seed, and how do I fix it?](#why-did-i-get-an-option-error-when-generating-a-seed-and-how-do-i-fix-it) +- [How do I generate seeds with 1 Orb Orbsanity and other extreme options?](#how-do-i-generate-seeds-with-1-orb-orbsanity-and-other-extreme-options) - [How do I check my player options in-game?](#how-do-i-check-my-player-options-in-game) - [How does the HUD work?](#how-does-the-hud-work) - [I think I found a bug, where should I report it?](#i-think-i-found-a-bug-where-should-i-report-it) @@ -201,16 +201,19 @@ Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `W Selecting this option will ask if you want to be teleported to Geyser Rock. From there, you can teleport back to the nearest sage's hut to continue your journey. -## Why did I get an Option Error when generating a seed and how do I fix it +## How do I generate seeds with 1 orb orbsanity and other extreme options? Depending on your player YAML, Jak and Daxter can have a lot of items, which can sometimes be overwhelming or disruptive to multiworld games. There are also options that are mutually incompatible with each other, even in a solo game. To prevent the game from disrupting multiworlds, or generating an impossible solo seed, some options have -Singleplayer and Multiplayer Minimums and Maximums, collectively called "friendly limits." +"friendly limits" that prevent you from choosing more extreme values. -If you're generating a solo game, or your multiworld host agrees to your request, you can override those limits by -editing the `host.yaml`. In the Archipelago Launcher, click `Open host.yaml`, then search for `jakanddaxter_options`, -then search for `enforce_friendly_options`, then change this value from `true` to `false`. Disabling this allows for -more disruptive and challenging options, but it may cause seed generation to fail. **Use at your own risk!** +You can override **some**, not all, of those limits by editing the `host.yaml`. In the Archipelago Launcher, click +`Open host.yaml`, then search for `jakanddaxter_options`, then search for `enforce_friendly_options`, then change this +value from `true` to `false`. You can then generate a seed locally, and upload that to the Archipelago website to host +for you (or host it yourself). + +**Remember:** disabling this setting allows for more disruptive and challenging options, but it may cause seed +generation to fail. **Use at your own risk!** ## How do I check my player options in-game When you connect your text client to the Archipelago Server, the server will tell the game what options were chosen diff --git a/worlds/jakanddaxter/docs/setup_en.md b/worlds/jakanddaxter/docs/setup_en.md index 509fb3ad8d..9cd892a9b2 100644 --- a/worlds/jakanddaxter/docs/setup_en.md +++ b/worlds/jakanddaxter/docs/setup_en.md @@ -4,7 +4,6 @@ - A legally purchased copy of *Jak And Daxter: The Precursor Legacy.* - [The OpenGOAL Launcher](https://opengoal.dev/) -- [The Jak and Daxter .APWORLD package](https://github.com/ArchipelaGOAL/Archipelago/releases) At this time, this method of setup works on Windows only, but Linux support is a strong likelihood in the near future as OpenGOAL itself supports Linux. @@ -75,7 +74,7 @@ If you are in the middle of an async game, and you do not want to update the mod ### New Game - Run the Archipelago Launcher. -- From the right-most list, find and click `Jak and Daxter Client`. +- From the client list, find and click `Jak and Daxter Client`. - 3 new windows should appear: - The OpenGOAL compiler will launch and compile the game. They should take about 30 seconds to compile. - You should hear a musical cue to indicate the compilation was a success. If you do not, see the Troubleshooting section. diff --git a/worlds/jakanddaxter/options.py b/worlds/jakanddaxter/options.py index bd007e264a..d36303b075 100644 --- a/worlds/jakanddaxter/options.py +++ b/worlds/jakanddaxter/options.py @@ -1,22 +1,78 @@ from dataclasses import dataclass from functools import cached_property -from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionCounter +from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionCounter, \ + AssembleOptions from .items import trap_item_table -class StaticGetter: - def __init__(self, func): - self.fget = func +class readonly_classproperty: + """This decorator is used for getting friendly or unfriendly range_end values for options like FireCanyonCellCount + and CitizenOrbTradeAmount. We only need to provide a getter as we will only be setting a single int to one of two + values.""" + def __init__(self, getter): + self.getter = getter def __get__(self, instance, owner): - return self.fget(owner) + return self.getter(owner) -@StaticGetter +@readonly_classproperty def determine_range_end(cls) -> int: - from . import JakAndDaxterWorld - enforce_friendly_options = JakAndDaxterWorld.settings.enforce_friendly_options - return cls.friendly_maximum if enforce_friendly_options else cls.absolute_maximum + from . import JakAndDaxterWorld # Avoid circular imports. + friendly = JakAndDaxterWorld.settings.enforce_friendly_options + return cls.friendly_maximum if friendly else cls.absolute_maximum + + +class classproperty: + """This decorator (?) is used for getting and setting friendly or unfriendly option values for the Orbsanity + options.""" + def __init__(self, getter, setter): + self.getter = getter + self.setter = setter + + def __get__(self, obj, value): + return self.getter(obj) + + def __set__(self, obj, value): + self.setter(obj, value) + + +class AllowedChoiceMeta(AssembleOptions): + """This metaclass overrides AssembleOptions and provides inheriting classes a way to filter out "disallowed" values + by way of implementing get_disallowed_options. This function is used by Jak and Daxter to check host.yaml settings + without circular imports or breaking the settings API.""" + _name_lookup: dict[int, str] + _options: dict[str, int] + + def __new__(mcs, name, bases, attrs): + ret = super().__new__(mcs, name, bases, attrs) + ret._name_lookup = attrs["name_lookup"] + ret._options = attrs["options"] + return ret + + def set_name_lookup(cls, value : dict[int, str]): + cls._name_lookup = value + + def get_name_lookup(cls) -> dict[int, str]: + cls._name_lookup = {k: v for k, v in cls._name_lookup.items() if k not in cls.get_disallowed_options()} + return cls._name_lookup + + def set_options(cls, value: dict[str, int]): + cls._options = value + + def get_options(cls) -> dict[str, int]: + cls._options = {k: v for k, v in cls._options.items() if v not in cls.get_disallowed_options()} + return cls._options + + def get_disallowed_options(cls): + return {} + + name_lookup = classproperty(get_name_lookup, set_name_lookup) + options = classproperty(get_options, set_options) + + +class AllowedChoice(Choice, metaclass=AllowedChoiceMeta): + pass class EnableMoveRandomizer(Toggle): @@ -44,12 +100,13 @@ class EnableOrbsanity(Choice): default = 0 -class GlobalOrbsanityBundleSize(Choice): +class GlobalOrbsanityBundleSize(AllowedChoice): """The orb bundle size for Global Orbsanity. This only applies if "Enable Orbsanity" is set to "Global." There are 2000 orbs in the game, so your bundle size must be a factor of 2000. - Multiplayer Minimum: 10 - Multiplayer Maximum: 200""" + This value is restricted to safe minimum and maximum values to ensure valid singleplayer games and + non-disruptive multiplayer games, but the host can remove this restriction by turning off enforce_friendly_options + in host.yaml.""" display_name = "Global Orbsanity Bundle Size" option_1_orb = 1 option_2_orbs = 2 @@ -75,12 +132,33 @@ class GlobalOrbsanityBundleSize(Choice): friendly_maximum = 200 default = 20 + @classmethod + def get_disallowed_options(cls) -> set[int]: + try: + from . import JakAndDaxterWorld + if JakAndDaxterWorld.settings.enforce_friendly_options: + return {cls.option_1_orb, + cls.option_2_orbs, + cls.option_4_orbs, + cls.option_5_orbs, + cls.option_8_orbs, + cls.option_250_orbs, + cls.option_400_orbs, + cls.option_500_orbs, + cls.option_1000_orbs, + cls.option_2000_orbs} + except ImportError: + pass + return set() -class PerLevelOrbsanityBundleSize(Choice): + +class PerLevelOrbsanityBundleSize(AllowedChoice): """The orb bundle size for Per Level Orbsanity. This only applies if "Enable Orbsanity" is set to "Per Level." There are 50, 150, or 200 orbs per level, so your bundle size must be a factor of 50. - Multiplayer Minimum: 10""" + This value is restricted to safe minimum and maximum values to ensure valid singleplayer games and + non-disruptive multiplayer games, but the host can remove this restriction by turning off enforce_friendly_options + in host.yaml.""" display_name = "Per Level Orbsanity Bundle Size" option_1_orb = 1 option_2_orbs = 2 @@ -91,6 +169,18 @@ class PerLevelOrbsanityBundleSize(Choice): friendly_minimum = 10 default = 25 + @classmethod + def get_disallowed_options(cls) -> set[int]: + try: + from . import JakAndDaxterWorld + if JakAndDaxterWorld.settings.enforce_friendly_options: + return {cls.option_1_orb, + cls.option_2_orbs, + cls.option_5_orbs} + except ImportError: + pass + return set() + class FireCanyonCellCount(Range): """The number of power cells you need to cross Fire Canyon. This value is restricted to a safe maximum value to @@ -234,7 +324,7 @@ class CompletionCondition(Choice): option_cross_fire_canyon = 69 option_cross_mountain_pass = 87 option_cross_lava_tube = 89 - option_defeat_dark_eco_plant = 6 + # option_defeat_dark_eco_plant = 6 option_defeat_klaww = 86 option_defeat_gol_and_maia = 112 option_open_100_cell_door = 116 diff --git a/worlds/jakanddaxter/regions.py b/worlds/jakanddaxter/regions.py index 8447f72e8e..87186c3a02 100644 --- a/worlds/jakanddaxter/regions.py +++ b/worlds/jakanddaxter/regions.py @@ -115,8 +115,8 @@ def create_regions(world: "JakAndDaxterWorld"): elif options.jak_completion_condition == CompletionCondition.option_cross_lava_tube: multiworld.completion_condition[player] = lambda state: state.can_reach(gmc, "Region", player) - elif options.jak_completion_condition == CompletionCondition.option_defeat_dark_eco_plant: - multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player) + # elif options.jak_completion_condition == CompletionCondition.option_defeat_dark_eco_plant: + # multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player) elif options.jak_completion_condition == CompletionCondition.option_defeat_klaww: multiworld.completion_condition[player] = lambda state: state.can_reach(mp, "Region", player) diff --git a/worlds/jakanddaxter/rules.py b/worlds/jakanddaxter/rules.py index 71b94df885..25a8323f4d 100644 --- a/worlds/jakanddaxter/rules.py +++ b/worlds/jakanddaxter/rules.py @@ -1,3 +1,5 @@ +import logging +import math import typing from BaseClasses import CollectionState from Options import OptionError @@ -131,100 +133,138 @@ def can_fight(state: CollectionState, player: int) -> bool: return state.has_any(("Jump Dive", "Jump Kick", "Punch", "Kick"), player) -def enforce_multiplayer_limits(world: "JakAndDaxterWorld"): +def clamp_cell_limits(world: "JakAndDaxterWorld") -> str: options = world.options friendly_message = "" - if (options.enable_orbsanity == EnableOrbsanity.option_global - and (options.global_orbsanity_bundle_size.value < GlobalOrbsanityBundleSize.friendly_minimum - or options.global_orbsanity_bundle_size.value > GlobalOrbsanityBundleSize.friendly_maximum)): - friendly_message += (f" " - f"{options.global_orbsanity_bundle_size.display_name} must be no less than " - f"{GlobalOrbsanityBundleSize.friendly_minimum} and no greater than " - f"{GlobalOrbsanityBundleSize.friendly_maximum} (currently " - f"{options.global_orbsanity_bundle_size.value}).\n") - - if (options.enable_orbsanity == EnableOrbsanity.option_per_level - and options.level_orbsanity_bundle_size.value < PerLevelOrbsanityBundleSize.friendly_minimum): - friendly_message += (f" " - f"{options.level_orbsanity_bundle_size.display_name} must be no less than " - f"{PerLevelOrbsanityBundleSize.friendly_minimum} (currently " - f"{options.level_orbsanity_bundle_size.value}).\n") - if options.fire_canyon_cell_count.value > FireCanyonCellCount.friendly_maximum: + old_value = options.fire_canyon_cell_count.value + options.fire_canyon_cell_count.value = FireCanyonCellCount.friendly_maximum friendly_message += (f" " f"{options.fire_canyon_cell_count.display_name} must be no greater than " - f"{FireCanyonCellCount.friendly_maximum} (currently " - f"{options.fire_canyon_cell_count.value}).\n") + f"{FireCanyonCellCount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.mountain_pass_cell_count.value > MountainPassCellCount.friendly_maximum: + old_value = options.mountain_pass_cell_count.value + options.mountain_pass_cell_count.value = MountainPassCellCount.friendly_maximum friendly_message += (f" " f"{options.mountain_pass_cell_count.display_name} must be no greater than " - f"{MountainPassCellCount.friendly_maximum} (currently " - f"{options.mountain_pass_cell_count.value}).\n") + f"{MountainPassCellCount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.lava_tube_cell_count.value > LavaTubeCellCount.friendly_maximum: + old_value = options.lava_tube_cell_count.value + options.lava_tube_cell_count.value = LavaTubeCellCount.friendly_maximum friendly_message += (f" " f"{options.lava_tube_cell_count.display_name} must be no greater than " - f"{LavaTubeCellCount.friendly_maximum} (currently " - f"{options.lava_tube_cell_count.value}).\n") + f"{LavaTubeCellCount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") + + return friendly_message + + +def clamp_trade_total_limits(world: "JakAndDaxterWorld"): + """Check if we need to recalculate the 2 trade orb options so the total fits under 2000. If so let's keep them + proportional relative to each other. Then we'll recalculate total_trade_orbs. Remember this situation is + only possible if both values are greater than 0, otherwise the absolute maximums would keep them under 2000.""" + options = world.options + friendly_message = "" + + world.total_trade_orbs = (9 * options.citizen_orb_trade_amount) + (6 * options.oracle_orb_trade_amount) + if world.total_trade_orbs > 2000: + old_total = world.total_trade_orbs + old_citizen_value = options.citizen_orb_trade_amount.value + old_oracle_value = options.oracle_orb_trade_amount.value + + coefficient = old_oracle_value / old_citizen_value + + options.citizen_orb_trade_amount.value = math.floor(2000 / (9 + (6 * coefficient))) + options.oracle_orb_trade_amount.value = math.floor(coefficient * options.citizen_orb_trade_amount.value) + world.total_trade_orbs = (9 * options.citizen_orb_trade_amount) + (6 * options.oracle_orb_trade_amount) + + friendly_message += (f" " + f"Required number of orbs ({old_total}) must be no greater than total orbs in the game " + f"(2000). Reduced the value of {world.options.citizen_orb_trade_amount.display_name} " + f"from {old_citizen_value} to {options.citizen_orb_trade_amount.value} and " + f"{world.options.oracle_orb_trade_amount.display_name} from {old_oracle_value} to " + f"{options.oracle_orb_trade_amount.value}.\n") + + return friendly_message + + +def enforce_mp_friendly_limits(world: "JakAndDaxterWorld"): + options = world.options + friendly_message = "" + + if options.enable_orbsanity == EnableOrbsanity.option_global: + if options.global_orbsanity_bundle_size.value < GlobalOrbsanityBundleSize.friendly_minimum: + old_value = options.global_orbsanity_bundle_size.value + options.global_orbsanity_bundle_size.value = GlobalOrbsanityBundleSize.friendly_minimum + friendly_message += (f" " + f"{options.global_orbsanity_bundle_size.display_name} must be no less than " + f"{GlobalOrbsanityBundleSize.friendly_minimum} (was {old_value}), " + f"changed option to appropriate value.\n") + + if options.global_orbsanity_bundle_size.value > GlobalOrbsanityBundleSize.friendly_maximum: + old_value = options.global_orbsanity_bundle_size.value + options.global_orbsanity_bundle_size.value = GlobalOrbsanityBundleSize.friendly_maximum + friendly_message += (f" " + f"{options.global_orbsanity_bundle_size.display_name} must be no greater than " + f"{GlobalOrbsanityBundleSize.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") + + if options.enable_orbsanity == EnableOrbsanity.option_per_level: + if options.level_orbsanity_bundle_size.value < PerLevelOrbsanityBundleSize.friendly_minimum: + old_value = options.level_orbsanity_bundle_size.value + options.level_orbsanity_bundle_size.value = PerLevelOrbsanityBundleSize.friendly_minimum + friendly_message += (f" " + f"{options.level_orbsanity_bundle_size.display_name} must be no less than " + f"{PerLevelOrbsanityBundleSize.friendly_minimum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.citizen_orb_trade_amount.value > CitizenOrbTradeAmount.friendly_maximum: + old_value = options.citizen_orb_trade_amount.value + options.citizen_orb_trade_amount.value = CitizenOrbTradeAmount.friendly_maximum friendly_message += (f" " f"{options.citizen_orb_trade_amount.display_name} must be no greater than " - f"{CitizenOrbTradeAmount.friendly_maximum} (currently " - f"{options.citizen_orb_trade_amount.value}).\n") + f"{CitizenOrbTradeAmount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.oracle_orb_trade_amount.value > OracleOrbTradeAmount.friendly_maximum: + old_value = options.oracle_orb_trade_amount.value + options.oracle_orb_trade_amount.value = OracleOrbTradeAmount.friendly_maximum friendly_message += (f" " f"{options.oracle_orb_trade_amount.display_name} must be no greater than " - f"{OracleOrbTradeAmount.friendly_maximum} (currently " - f"{options.oracle_orb_trade_amount.value}).\n") + f"{OracleOrbTradeAmount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") + + friendly_message += clamp_cell_limits(world) + friendly_message += clamp_trade_total_limits(world) if friendly_message != "": - raise OptionError(f"{world.player_name}: The options you have chosen may disrupt the multiworld. \n" - f"Please adjust the following Options for a multiplayer game. \n" - f"{friendly_message}" - f"Or use 'random-range-x-y' instead of 'random' in your player yaml.\n" - f"Or set 'enforce_friendly_options' in the seed generator's host.yaml to false. " - f"(Use at your own risk!)") + logging.warning(f"{world.player_name}: Your options have been modified to avoid disrupting the multiworld.\n" + f"{friendly_message}" + f"You can access more advanced options by setting 'enforce_friendly_options' in the seed " + f"generator's host.yaml to false and generating locally. (Use at your own risk!)") -def enforce_singleplayer_limits(world: "JakAndDaxterWorld"): - options = world.options +def enforce_mp_absolute_limits(world: "JakAndDaxterWorld"): friendly_message = "" - if options.fire_canyon_cell_count.value > FireCanyonCellCount.friendly_maximum: - friendly_message += (f" " - f"{options.fire_canyon_cell_count.display_name} must be no greater than " - f"{FireCanyonCellCount.friendly_maximum} (currently " - f"{options.fire_canyon_cell_count.value}).\n") - - if options.mountain_pass_cell_count.value > MountainPassCellCount.friendly_maximum: - friendly_message += (f" " - f"{options.mountain_pass_cell_count.display_name} must be no greater than " - f"{MountainPassCellCount.friendly_maximum} (currently " - f"{options.mountain_pass_cell_count.value}).\n") - - if options.lava_tube_cell_count.value > LavaTubeCellCount.friendly_maximum: - friendly_message += (f" " - f"{options.lava_tube_cell_count.display_name} must be no greater than " - f"{LavaTubeCellCount.friendly_maximum} (currently " - f"{options.lava_tube_cell_count.value}).\n") + friendly_message += clamp_trade_total_limits(world) if friendly_message != "": - raise OptionError(f"The options you have chosen may result in seed generation failures. \n" - f"Please adjust the following Options for a singleplayer game. \n" - f"{friendly_message}" - f"Or use 'random-range-x-y' instead of 'random' in your player yaml.\n" - f"Or set 'enforce_friendly_options' in your host.yaml to false. " - f"(Use at your own risk!)") + logging.warning(f"{world.player_name}: Your options have been modified to avoid seed generation failures.\n" + f"{friendly_message}") -def verify_orb_trade_amounts(world: "JakAndDaxterWorld"): +def enforce_sp_limits(world: "JakAndDaxterWorld"): + friendly_message = "" - if world.total_trade_orbs > 2000: - raise OptionError(f"{world.player_name}: Required number of orbs for all trades ({world.total_trade_orbs}) " - f"is more than all the orbs in the game (2000). Reduce the value of either " - f"{world.options.citizen_orb_trade_amount.display_name} " - f"or {world.options.oracle_orb_trade_amount.display_name}.") + friendly_message += clamp_cell_limits(world) + friendly_message += clamp_trade_total_limits(world) + + if friendly_message != "": + logging.warning(f"{world.player_name}: Your options have been modified to avoid seed generation failures.\n" + f"{friendly_message}") diff --git a/worlds/jakanddaxter/test/test_trades.py b/worlds/jakanddaxter/test/test_trades.py index e1d1a2e53d..0277a92353 100644 --- a/worlds/jakanddaxter/test/test_trades.py +++ b/worlds/jakanddaxter/test/test_trades.py @@ -4,14 +4,14 @@ from .bases import JakAndDaxterTestBase class TradesCostNothingTest(JakAndDaxterTestBase): options = { "enable_orbsanity": 2, - "global_orbsanity_bundle_size": 5, + "global_orbsanity_bundle_size": 10, "citizen_orb_trade_amount": 0, "oracle_orb_trade_amount": 0 } def test_orb_items_are_filler(self): self.collect_all_but("") - self.assertNotIn("5 Precursor Orbs", self.multiworld.state.prog_items) + self.assertNotIn("10 Precursor Orbs", self.multiworld.state.prog_items) def test_trades_are_accessible(self): self.assertTrue(self.multiworld @@ -22,15 +22,15 @@ class TradesCostNothingTest(JakAndDaxterTestBase): class TradesCostEverythingTest(JakAndDaxterTestBase): options = { "enable_orbsanity": 2, - "global_orbsanity_bundle_size": 5, + "global_orbsanity_bundle_size": 10, "citizen_orb_trade_amount": 120, "oracle_orb_trade_amount": 150 } def test_orb_items_are_progression(self): self.collect_all_but("") - self.assertIn("5 Precursor Orbs", self.multiworld.state.prog_items[self.player]) - self.assertEqual(396, self.multiworld.state.prog_items[self.player]["5 Precursor Orbs"]) + self.assertIn("10 Precursor Orbs", self.multiworld.state.prog_items[self.player]) + self.assertEqual(198, self.multiworld.state.prog_items[self.player]["10 Precursor Orbs"]) def test_trades_are_accessible(self): self.collect_all_but("") diff --git a/worlds/kdl3/client.py b/worlds/kdl3/client.py index 97bf68cbd9..78a43239b4 100644 --- a/worlds/kdl3/client.py +++ b/worlds/kdl3/client.py @@ -90,7 +90,7 @@ def cmd_gift(self: "SNIClientCommandProcessor") -> None: async_start(update_object(self.ctx, f"Giftboxes;{self.ctx.team}", { f"{self.ctx.slot}": { - "IsOpen": handler.gifting, + "is_open": handler.gifting, **kdl3_gifting_options } })) @@ -175,11 +175,11 @@ class KDL3SNIClient(SNIClient): key, gift = ctx.stored_data[self.giftbox_key].popitem() await pop_object(ctx, self.giftbox_key, key) # first, special cases - traits = [trait["Trait"] for trait in gift["Traits"]] + traits = [trait["trait"] for trait in gift["traits"]] if "Candy" in traits or "Invincible" in traits: # apply invincibility candy self.item_queue.append(0x43) - elif "Tomato" in traits or "tomato" in gift["ItemName"].lower(): + elif "Tomato" in traits or "tomato" in gift["item_name"].lower(): # apply maxim tomato # only want tomatos here, no other vegetable is that good self.item_queue.append(0x42) @@ -187,7 +187,7 @@ class KDL3SNIClient(SNIClient): # Apply 1-Up self.item_queue.append(0x41) elif "Currency" in traits or "Star" in traits: - value = gift["ItemValue"] + value = gift.get("item_value", 1) if value >= 50000: self.item_queue.append(0x46) elif value >= 30000: @@ -210,8 +210,8 @@ class KDL3SNIClient(SNIClient): # check if it's tasty if any(x in traits for x in ["Consumable", "Food", "Drink", "Heal", "Health"]): # it's tasty!, use quality to decide how much to heal - quality = max((trait["Quality"] for trait in gift["Traits"] - if trait["Trait"] in ["Consumable", "Food", "Drink", "Heal", "Health"])) + quality = max((trait.get("quality", 1.0) for trait in gift["traits"] + if trait["trait"] in ["Consumable", "Food", "Drink", "Heal", "Health"])) quality = min(10, quality * 2) else: # it's not really edible, but he'll eat it anyway @@ -236,23 +236,23 @@ class KDL3SNIClient(SNIClient): for slot, info in ctx.stored_data[self.motherbox_key].items(): if int(slot) == ctx.slot and len(ctx.stored_data[self.motherbox_key]) > 1: continue - desire = len(set(info["DesiredTraits"]).intersection([trait["Trait"] for trait in gift_base["Traits"]])) + desire = len(set(info["desired_traits"]).intersection([trait["trait"] for trait in gift_base["traits"]])) if desire > most_applicable: most_applicable = desire most_applicable_slot = int(slot) - elif most_applicable_slot != ctx.slot and most_applicable == -1 and info["AcceptsAnyGift"]: + elif most_applicable_slot == ctx.slot and most_applicable == -1 and info["accepts_any_gift"]: # only send to ourselves if no one else will take it most_applicable_slot = int(slot) # print(most_applicable, most_applicable_slot) item_uuid = uuid.uuid4().hex item = { **gift_base, - "ID": item_uuid, - "Sender": ctx.player_names[ctx.slot], - "Receiver": ctx.player_names[most_applicable_slot], - "SenderTeam": ctx.team, - "ReceiverTeam": ctx.team, # for the moment - "IsRefund": False + "id": item_uuid, + "sender_slot": ctx.slot, + "receiver_slot": most_applicable_slot, + "sender_team": ctx.team, + "receiver_team": ctx.team, # for the moment + "is_refund": False } # print(item) await update_object(ctx, f"Giftbox;{ctx.team};{most_applicable_slot}", { @@ -276,8 +276,9 @@ class KDL3SNIClient(SNIClient): if not self.initialize_gifting: self.giftbox_key = f"Giftbox;{ctx.team};{ctx.slot}" self.motherbox_key = f"Giftboxes;{ctx.team}" - enable_gifting = await snes_read(ctx, KDL3_GIFTING_FLAG, 0x01) - await initialize_giftboxes(ctx, self.giftbox_key, self.motherbox_key, bool(enable_gifting[0])) + enable_gifting = await snes_read(ctx, KDL3_GIFTING_FLAG, 0x02) + await initialize_giftboxes(ctx, self.giftbox_key, self.motherbox_key, + bool(int.from_bytes(enable_gifting, "little"))) self.initialize_gifting = True # can't check debug anymore, without going and copying the value. might be important later. if not self.levels: @@ -350,19 +351,19 @@ class KDL3SNIClient(SNIClient): self.item_queue.append(item_idx | 0x80) # handle gifts here - gifting_status = await snes_read(ctx, KDL3_GIFTING_FLAG, 0x01) - if hasattr(ctx, "gifting") and ctx.gifting: - if gifting_status[0]: + gifting_status = int.from_bytes(await snes_read(ctx, KDL3_GIFTING_FLAG, 0x02), "little") + if hasattr(self, "gifting") and self.gifting: + if gifting_status: gift = await snes_read(ctx, KDL3_GIFTING_SEND, 0x01) if gift[0]: # we have a gift to send await self.pick_gift_recipient(ctx, gift[0]) snes_buffered_write(ctx, KDL3_GIFTING_SEND, bytes([0x00])) else: - snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x01])) + snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x01, 0x00])) else: - if gifting_status[0]: - snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x00])) + if gifting_status: + snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x00, 0x00])) await snes_flush_writes(ctx) diff --git a/worlds/kdl3/gifting.py b/worlds/kdl3/gifting.py index e162609100..de15514874 100644 --- a/worlds/kdl3/gifting.py +++ b/worlds/kdl3/gifting.py @@ -37,157 +37,158 @@ async def initialize_giftboxes(ctx: "SNIContext", giftbox_key: str, motherbox_ke ctx.set_notify(motherbox_key, giftbox_key) await update_object(ctx, f"Giftboxes;{ctx.team}", {f"{ctx.slot}": { - "IsOpen": is_open, + "is_open": is_open, **kdl3_gifting_options }}) + await update_object(ctx, f"Giftbox;{ctx.team};{ctx.slot}", {}) ctx.client_handler.gifting = is_open kdl3_gifting_options = { - "AcceptsAnyGift": True, - "DesiredTraits": [ + "accepts_any_gift": True, + "desired_traits": [ "Consumable", "Food", "Drink", "Candy", "Tomato", "Invincible", "Life", "Heal", "Health", "Trap", "Goo", "Gel", "Slow", "Slowness", "Eject", "Removal" ], - "MinimumGiftVersion": 2, + "minimum_gift_version": 3, } kdl3_gifts = { 1: { - "ItemName": "1-Up", - "Amount": 1, - "ItemValue": 400000, - "Traits": [ + "item_name": "1-Up", + "amount": 1, + "item_value": 400000, + "traits": [ { - "Trait": "Consumable", - "Quality": 1, - "Duration": 1, + "trait": "Consumable", + "quality": 1, + "duration": 1, }, { - "Trait": "Life", - "Quality": 1, - "Duration": 1 + "trait": "Life", + "quality": 1, + "duration": 1 } ] }, 2: { - "ItemName": "Maxim Tomato", - "Amount": 1, - "ItemValue": 500000, - "Traits": [ + "item_name": "Maxim Tomato", + "amount": 1, + "item_value": 500000, + "traits": [ { - "Trait": "Consumable", - "Quality": 5, - "Duration": 1, + "trait": "Consumable", + "quality": 5, + "duration": 1, }, { - "Trait": "Heal", - "Quality": 5, - "Duration": 1, + "trait": "Heal", + "quality": 5, + "duration": 1, }, { - "Trait": "Food", - "Quality": 5, - "Duration": 1, + "trait": "Food", + "quality": 5, + "duration": 1, }, { - "Trait": "Tomato", - "Quality": 5, - "Duration": 1, + "trait": "Tomato", + "quality": 5, + "duration": 1, }, { - "Trait": "Vegetable", - "Quality": 5, - "Duration": 1, + "trait": "Vegetable", + "quality": 5, + "duration": 1, } ] }, 3: { - "ItemName": "Energy Drink", - "Amount": 1, - "ItemValue": 100000, - "Traits": [ + "item_name": "Energy Drink", + "amount": 1, + "item_value": 100000, + "traits": [ { - "Trait": "Consumable", - "Quality": 1, - "Duration": 1, + "trait": "Consumable", + "quality": 1, + "duration": 1, }, { - "Trait": "Heal", - "Quality": 1, - "Duration": 1, + "trait": "Heal", + "quality": 1, + "duration": 1, }, { - "Trait": "Drink", - "Quality": 1, - "Duration": 1, + "trait": "Drink", + "quality": 1, + "duration": 1, }, ] }, 5: { - "ItemName": "Small Star Piece", - "Amount": 1, - "ItemValue": 10000, - "Traits": [ + "item_name": "Small Star Piece", + "amount": 1, + "item_value": 10000, + "traits": [ { - "Trait": "Currency", - "Quality": 1, - "Duration": 1, + "trait": "Currency", + "quality": 1, + "duration": 1, }, { - "Trait": "Money", - "Quality": 1, - "Duration": 1, + "trait": "Money", + "quality": 1, + "duration": 1, }, { - "Trait": "Star", - "Quality": 1, - "Duration": 1 + "trait": "Star", + "quality": 1, + "duration": 1 } ] }, 6: { - "ItemName": "Medium Star Piece", - "Amount": 1, - "ItemValue": 30000, - "Traits": [ + "item_name": "Medium Star Piece", + "amount": 1, + "item_value": 30000, + "traits": [ { - "Trait": "Currency", - "Quality": 3, - "Duration": 1, + "trait": "Currency", + "quality": 3, + "duration": 1, }, { - "Trait": "Money", - "Quality": 3, - "Duration": 1, + "trait": "Money", + "quality": 3, + "duration": 1, }, { - "Trait": "Star", - "Quality": 3, - "Duration": 1 + "trait": "Star", + "quality": 3, + "duration": 1 } ] }, 7: { - "ItemName": "Large Star Piece", - "Amount": 1, - "ItemValue": 50000, - "Traits": [ + "item_name": "Large Star Piece", + "amount": 1, + "item_value": 50000, + "traits": [ { - "Trait": "Currency", - "Quality": 5, - "Duration": 1, + "trait": "Currency", + "quality": 5, + "duration": 1, }, { - "Trait": "Money", - "Quality": 5, - "Duration": 1, + "trait": "Money", + "quality": 5, + "duration": 1, }, { - "Trait": "Star", - "Quality": 5, - "Duration": 1 + "trait": "Star", + "quality": 5, + "duration": 1 } ] }, @@ -195,90 +196,90 @@ kdl3_gifts = { kdl3_trap_gifts = { 0: { - "ItemName": "Gooey Bag", - "Amount": 1, - "ItemValue": 10000, - "Traits": [ + "item_name": "Gooey Bag", + "amount": 1, + "item_value": 10000, + "traits": [ { - "Trait": "Trap", - "Quality": 1, - "Duration": 1, + "trait": "Trap", + "quality": 1, + "duration": 1, }, { - "Trait": "Goo", - "Quality": 1, - "Duration": 1, + "trait": "Goo", + "quality": 1, + "duration": 1, }, { - "Trait": "Gel", - "Quality": 1, - "Duration": 1 + "trait": "Gel", + "quality": 1, + "duration": 1 } ] }, 1: { - "ItemName": "Slowness", - "Amount": 1, - "ItemValue": 10000, - "Traits": [ + "item_name": "Slowness", + "amount": 1, + "item_value": 10000, + "traits": [ { - "Trait": "Trap", - "Quality": 1, - "Duration": 1, + "trait": "Trap", + "quality": 1, + "duration": 1, }, { - "Trait": "Slow", - "Quality": 1, - "Duration": 1, + "trait": "Slow", + "quality": 1, + "duration": 1, }, { - "Trait": "Slowness", - "Quality": 1, - "Duration": 1 + "trait": "Slowness", + "quality": 1, + "duration": 1 } ] }, 2: { - "ItemName": "Eject Ability", - "Amount": 1, - "ItemValue": 10000, - "Traits": [ + "item_name": "Eject Ability", + "amount": 1, + "item_value": 10000, + "traits": [ { - "Trait": "Trap", - "Quality": 1, - "Duration": 1, + "trait": "Trap", + "quality": 1, + "duration": 1, }, { - "Trait": "Eject", - "Quality": 1, - "Duration": 1, + "trait": "Eject", + "quality": 1, + "duration": 1, }, { - "Trait": "Removal", - "Quality": 1, - "Duration": 1 + "trait": "Removal", + "quality": 1, + "duration": 1 } ] }, 3: { - "ItemName": "Bad Meal", - "Amount": 1, - "ItemValue": 10000, - "Traits": [ + "item_name": "Bad Meal", + "amount": 1, + "item_value": 10000, + "traits": [ { - "Trait": "Trap", - "Quality": 1, - "Duration": 1, + "trait": "Trap", + "quality": 1, + "duration": 1, }, { - "Trait": "Damage", - "Quality": 1, - "Duration": 1, + "trait": "Damage", + "quality": 1, + "duration": 1, }, { - "Trait": "Food", - "Quality": 1, - "Duration": 1 + "trait": "Food", + "quality": 1, + "duration": 1 } ] }, diff --git a/worlds/kdl3/options.py b/worlds/kdl3/options.py index b9163794ad..77095bfec6 100644 --- a/worlds/kdl3/options.py +++ b/worlds/kdl3/options.py @@ -289,7 +289,7 @@ class KirbyFlavorPreset(Choice): option_lime = 12 option_lavender = 13 option_miku = 14 - option_custom = 15 + option_custom = -1 default = 0 @classmethod @@ -297,7 +297,7 @@ class KirbyFlavorPreset(Choice): text = text.lower() if text == "random": choice_list = list(cls.name_lookup) - choice_list.remove(14) + choice_list.remove(-1) return cls(random.choice(choice_list)) return super().from_text(text) @@ -347,7 +347,7 @@ class GooeyFlavorPreset(Choice): option_orange = 11 option_lime = 12 option_lavender = 13 - option_custom = 14 + option_custom = -1 default = 0 @classmethod @@ -355,7 +355,7 @@ class GooeyFlavorPreset(Choice): text = text.lower() if text == "random": choice_list = list(cls.name_lookup) - choice_list.remove(14) + choice_list.remove(-1) return cls(random.choice(choice_list)) return super().from_text(text) diff --git a/worlds/kdl3/rom.py b/worlds/kdl3/rom.py index 741ea00830..5f986bc4be 100644 --- a/worlds/kdl3/rom.py +++ b/worlds/kdl3/rom.py @@ -7,7 +7,6 @@ import hashlib import os import struct -import settings from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes, APPatchExtension from .aesthetics import get_palette_bytes, kirby_target_palettes, get_kirby_palette, gooey_target_palettes, \ get_gooey_palette @@ -475,8 +474,7 @@ def patch_rom(world: "KDL3World", patch: KDL3ProcedurePatch) -> None: patch.write_token(APTokenTypes.WRITE, 0x3D016, world.options.ow_boss_requirement.value.to_bytes(2, "little")) patch.write_token(APTokenTypes.WRITE, 0x3D018, world.options.consumables.value.to_bytes(2, "little")) patch.write_token(APTokenTypes.WRITE, 0x3D01A, world.options.starsanity.value.to_bytes(2, "little")) - patch.write_token(APTokenTypes.WRITE, 0x3D01C, world.options.gifting.value.to_bytes(2, "little") - if world.multiworld.players > 1 else bytes([0, 0])) + patch.write_token(APTokenTypes.WRITE, 0x3D01C, world.options.gifting.value.to_bytes(2, "little")) patch.write_token(APTokenTypes.WRITE, 0x3D01E, world.options.strict_bosses.value.to_bytes(2, "little")) # don't write gifting for solo game, since there's no one to send anything to @@ -594,9 +592,9 @@ def get_base_rom_bytes() -> bytes: def get_base_rom_path(file_name: str = "") -> str: - options: settings.Settings = settings.get_settings() + from . import KDL3World if not file_name: - file_name = options["kdl3_options"]["rom_file"] + file_name = KDL3World.settings.rom_file if not os.path.exists(file_name): file_name = Utils.user_path(file_name) return file_name diff --git a/worlds/kh1/Options.py b/worlds/kh1/Options.py index 63732f61b2..7a79d5c1ea 100644 --- a/worlds/kh1/Options.py +++ b/worlds/kh1/Options.py @@ -287,13 +287,13 @@ class BadStartingWeapons(Toggle): class DonaldDeathLink(Toggle): """ - If Donald is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone. + If Donald is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone who enabled death link. """ display_name = "Donald Death Link" class GoofyDeathLink(Toggle): """ - If Goofy is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone. + If Goofy is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone who enabled death link. """ display_name = "Goofy Death Link" diff --git a/worlds/kh2/Client.py b/worlds/kh2/Client.py index 96b406c72f..5a26231c0c 100644 --- a/worlds/kh2/Client.py +++ b/worlds/kh2/Client.py @@ -34,7 +34,7 @@ class KH2Context(CommonContext): self.growthlevel = None self.kh2connected = False self.kh2_finished_game = False - self.serverconneced = False + self.serverconnected = 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.kh2_data_package = {} @@ -47,6 +47,8 @@ class KH2Context(CommonContext): self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()} self.sending = [] + self.slot_name = None + self.disconnect_from_server = False # list used to keep track of locations+items player has. Used for disoneccting self.kh2_seed_save_cache = { "itemIndex": -1, @@ -185,11 +187,20 @@ class KH2Context(CommonContext): if password_requested and not self.password: await super(KH2Context, self).server_auth(password_requested) await self.get_username() - await self.send_connect() + # if slot name != first time login or previous name + # and seed name is none or saved seed name + if not self.slot_name and not self.kh2seedname: + await self.send_connect() + elif self.slot_name == self.auth and self.kh2seedname: + await self.send_connect() + else: + logger.info(f"You are trying to connect with data still cached in the client. Close client or connect to the correct slot: {self.slot_name}") + self.serverconnected = False + self.disconnect_from_server = True async def connection_closed(self): self.kh2connected = False - self.serverconneced = False + self.serverconnected = False if self.kh2seedname is not None and self.auth is not None: with open(self.kh2_seed_save_path_join, 'w') as f: f.write(json.dumps(self.kh2_seed_save, indent=4)) @@ -197,7 +208,8 @@ class KH2Context(CommonContext): async def disconnect(self, allow_autoreconnect: bool = False): self.kh2connected = False - self.serverconneced = False + self.serverconnected = False + self.locations_checked = [] if self.kh2seedname not in {None} and self.auth not in {None}: with open(self.kh2_seed_save_path_join, 'w') as f: f.write(json.dumps(self.kh2_seed_save, indent=4)) @@ -239,7 +251,15 @@ class KH2Context(CommonContext): def on_package(self, cmd: str, args: dict): if cmd == "RoomInfo": - self.kh2seedname = args['seed_name'] + if not self.kh2seedname: + self.kh2seedname = args['seed_name'] + elif self.kh2seedname != args['seed_name']: + self.disconnect_from_server = True + self.serverconnected = False + self.kh2connected = False + logger.info("Connection to the wrong seed, connect to the correct seed or close the client.") + return + self.kh2_seed_save_path = f"kh2save2{self.kh2seedname}{self.auth}.json" self.kh2_seed_save_path_join = os.path.join(self.game_communication_path, self.kh2_seed_save_path) @@ -338,7 +358,7 @@ class KH2Context(CommonContext): }, }, } - if start_index > self.kh2_seed_save_cache["itemIndex"] and self.serverconneced: + if start_index > self.kh2_seed_save_cache["itemIndex"] and self.serverconnected: self.kh2_seed_save_cache["itemIndex"] = start_index for item in args['items']: asyncio.create_task(self.give_item(item.item, item.location)) @@ -370,12 +390,14 @@ class KH2Context(CommonContext): if not self.kh2: self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") self.get_addresses() - +# except Exception as e: if self.kh2connected: self.kh2connected = False logger.info("Game is not open.") - self.serverconneced = True + + self.serverconnected = True + self.slot_name = self.auth def data_package_kh2_cache(self, loc_to_id, item_to_id): self.kh2_loc_name_to_id = loc_to_id @@ -493,23 +515,38 @@ class KH2Context(CommonContext): async def give_item(self, item, location): try: - # todo: ripout all the itemtype stuff and just have one dictionary. the only thing that needs to be tracked from the server/local is abilites - #sleep so we can get the datapackage and not miss any items that were sent to us while we didnt have our item id dicts + # sleep so we can get the datapackage and not miss any items that were sent to us while we didnt have our item id dicts while not self.lookup_id_to_item: await asyncio.sleep(0.5) itemname = self.lookup_id_to_item[item] itemdata = self.item_name_to_data[itemname] - # itemcode = self.kh2_item_name_to_id[itemname] if itemdata.ability: if location in self.all_weapon_location_id: return + # growth have reserved ability slots because of how the goa handles them if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}: self.kh2_seed_save_cache["AmountInvo"]["Growth"][itemname] += 1 return if itemname not in self.kh2_seed_save_cache["AmountInvo"]["Ability"]: self.kh2_seed_save_cache["AmountInvo"]["Ability"][itemname] = [] - # appending the slot that the ability should be in + # appending the slot that the ability should be in + # abilities have a limit amount of slots. + # we start from the back going down to not mess with stuff. + # Front of Invo + # Sora: Save+24F0+0x54 : 0x2546 + # Donald: Save+2604+0x54 : 0x2658 + # Goofy: Save+2718+0x54 : 0x276C + # Back of Invo. Sora has 6 ability slots that are reserved + # Sora: Save+24F0+0x54+0x92 : 0x25D8 + # Donald: Save+2604+0x54+0x9C : 0x26F4 + # Goofy: Save+2718+0x54+0x9C : 0x2808 + # seed has 2 scans in sora's abilities + # recieved second scan + # if len(seed_save(Scan:[ability slot 52]) < (2)amount of that ability they should have from slot data + # ability_slot = back of inventory that isnt taken + # add ability_slot to seed_save(Scan[]) so now its Scan:[ability slot 52,50] + # decrease back of inventory since its ability_slot is already taken if len(self.kh2_seed_save_cache["AmountInvo"]["Ability"][itemname]) < \ self.AbilityQuantityDict[itemname]: if itemname in self.sora_ability_set: @@ -528,18 +565,21 @@ class KH2Context(CommonContext): if ability_slot in self.front_ability_slots: self.front_ability_slots.remove(ability_slot) + # if itemdata in {bitmask} all the forms,summons and a few other things are bitmasks elif itemdata.memaddr in {0x36C4, 0x36C5, 0x36C6, 0x36C0, 0x36CA}: # if memaddr is in a bitmask location in memory if itemname not in self.kh2_seed_save_cache["AmountInvo"]["Bitmask"]: self.kh2_seed_save_cache["AmountInvo"]["Bitmask"].append(itemname) + # if itemdata in {magic} elif itemdata.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}: - # if memaddr is in magic addresses self.kh2_seed_save_cache["AmountInvo"]["Magic"][itemname] += 1 + # equipment is a list instead of dict because you can only have 1 currently elif itemname in self.all_equipment: self.kh2_seed_save_cache["AmountInvo"]["Equipment"].append(itemname) + # weapons are done differently since you can only have one and has to check it differently elif itemname in self.all_weapons: if itemname in self.keyblade_set: self.kh2_seed_save_cache["AmountInvo"]["Weapon"]["Sora"].append(itemname) @@ -548,9 +588,11 @@ class KH2Context(CommonContext): else: self.kh2_seed_save_cache["AmountInvo"]["Weapon"]["Goofy"].append(itemname) + # TODO: this can just be removed and put into the else below it elif itemname in self.stat_increase_set: self.kh2_seed_save_cache["AmountInvo"]["StatIncrease"][itemname] += 1 else: + # "normal" items. They have a unique byte reserved for how many they have if itemname in self.kh2_seed_save_cache["AmountInvo"]["Amount"]: self.kh2_seed_save_cache["AmountInvo"]["Amount"][itemname] += 1 else: @@ -930,7 +972,7 @@ def finishedGame(ctx: KH2Context): async def kh2_watcher(ctx: KH2Context): while not ctx.exit_event.is_set(): try: - if ctx.kh2connected and ctx.serverconneced: + if ctx.kh2connected and ctx.serverconnected: ctx.sending = [] await asyncio.create_task(ctx.checkWorldLocations()) await asyncio.create_task(ctx.checkLevels()) @@ -944,13 +986,19 @@ async def kh2_watcher(ctx: KH2Context): if ctx.sending: message = [{"cmd": 'LocationChecks', "locations": ctx.sending}] await ctx.send_msgs(message) - elif not ctx.kh2connected and ctx.serverconneced: - logger.info("Game Connection lost. waiting 15 seconds until trying to reconnect.") + elif not ctx.kh2connected and ctx.serverconnected: + logger.info("Game Connection lost. trying to reconnect.") ctx.kh2 = None - while not ctx.kh2connected and ctx.serverconneced: - await asyncio.sleep(15) - ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") - ctx.get_addresses() + while not ctx.kh2connected and ctx.serverconnected: + try: + ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") + ctx.get_addresses() + logger.info("Game Connection Established.") + except Exception as e: + await asyncio.sleep(5) + if ctx.disconnect_from_server: + ctx.disconnect_from_server = False + await ctx.disconnect() except Exception as e: if ctx.kh2connected: ctx.kh2connected = False diff --git a/worlds/kh2/OpenKH.py b/worlds/kh2/OpenKH.py index 985c9913ab..7c67fc07de 100644 --- a/worlds/kh2/OpenKH.py +++ b/worlds/kh2/OpenKH.py @@ -13,6 +13,7 @@ from worlds.Files import APPlayerContainer class KH2Container(APPlayerContainer): game: str = 'Kingdom Hearts 2' + patch_file_ending = ".zip" def __init__(self, patch_data: dict, base_path: str, output_directory: str, player=None, player_name: str = "", server: str = ""): diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py index defb285d50..19c2aee61f 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -277,9 +277,7 @@ class KH2World(World): if self.options.FillerItemsLocal: for item in filler_items: self.options.local_items.value.add(item) - # By imitating remote this doesn't have to be plandoded filler anymore - # for location in {LocationName.JunkMedal, LocationName.JunkMedal}: - # self.plando_locations[location] = random_stt_item + if not self.options.SummonLevelLocationToggle: self.total_locations -= 6 @@ -400,6 +398,8 @@ class KH2World(World): # plando goofy get bonuses goofy_get_bonus_location_pool = [self.multiworld.get_location(location, self.player) for location in Goofy_Checks.keys() if Goofy_Checks[location].yml != "Keyblade"] + if len(goofy_get_bonus_location_pool) > len(self.goofy_get_bonus_abilities): + raise Exception(f"Too little abilities to fill goofy get bonus locations for player {self.player_name}.") for location in goofy_get_bonus_location_pool: self.random.choice(self.goofy_get_bonus_abilities) random_ability = self.random.choice(self.goofy_get_bonus_abilities) @@ -416,11 +416,12 @@ class KH2World(World): random_ability = self.random.choice(self.donald_weapon_abilities) location.place_locked_item(random_ability) self.donald_weapon_abilities.remove(random_ability) - + # if option is turned off if not self.options.DonaldGoofyStatsanity: - # plando goofy get bonuses donald_get_bonus_location_pool = [self.multiworld.get_location(location, self.player) for location in Donald_Checks.keys() if Donald_Checks[location].yml != "Keyblade"] + if len(donald_get_bonus_location_pool) > len(self.donald_get_bonus_abilities): + raise Exception(f"Too little abilities to fill donald get bonus locations for player {self.player_name}.") for location in donald_get_bonus_location_pool: random_ability = self.random.choice(self.donald_get_bonus_abilities) location.place_locked_item(random_ability) diff --git a/worlds/ladx/LADXR/generator.py b/worlds/ladx/LADXR/generator.py index 413bf89c06..81ca666010 100644 --- a/worlds/ladx/LADXR/generator.py +++ b/worlds/ladx/LADXR/generator.py @@ -2,13 +2,15 @@ import binascii import importlib.util import importlib.machinery import os -import pkgutil +import random +import pickle +import Utils +import settings from collections import defaultdict -from typing import TYPE_CHECKING +from typing import Dict from .romTables import ROMWithTables from . import assembler -from . import mapgen from . import patches from .patches import overworld as _ from .patches import dungeon as _ @@ -57,27 +59,20 @@ from .patches import tradeSequence as _ from . import hints from .patches import bank34 -from .utils import formatText from .roomEditor import RoomEditor, Object from .patches.aesthetics import rgb_to_bin, bin_to_rgb -from .locations.keyLocation import KeyLocation - -from BaseClasses import ItemClassification -from ..Locations import LinksAwakeningLocation -from ..Options import TrendyGame, Palette, MusicChangeCondition, Warps - -if TYPE_CHECKING: - from .. import LinksAwakeningWorld - +from .. import Options # Function to generate a final rom, this patches the rom with all required patches -def generateRom(args, world: "LinksAwakeningWorld"): +def generateRom(base_rom: bytes, args, patch_data: Dict): + random.seed(patch_data["seed"] + patch_data["player"]) + multi_key = binascii.unhexlify(patch_data["multi_key"].encode()) + item_list = pickle.loads(binascii.unhexlify(patch_data["item_list"].encode())) + options = patch_data["options"] rom_patches = [] - player_names = list(world.multiworld.player_name.values()) - - rom = ROMWithTables(args.input_filename, rom_patches) - rom.player_names = player_names + rom = ROMWithTables(base_rom, rom_patches) + rom.player_names = patch_data["other_player_names"] pymods = [] if args.pymod: for pymod in args.pymod: @@ -88,10 +83,13 @@ def generateRom(args, world: "LinksAwakeningWorld"): for pymod in pymods: pymod.prePatch(rom) - if world.ladxr_settings.gfxmod: - patches.aesthetics.gfxMod(rom, os.path.join("data", "sprites", "ladx", world.ladxr_settings.gfxmod)) - - item_list = [item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)] + if options["gfxmod"]: + user_settings = settings.get_settings() + try: + gfx_mod_file = user_settings["ladx_options"]["gfx_mod_file"] + patches.aesthetics.gfxMod(rom, gfx_mod_file) + except FileNotFoundError: + pass # if user just doesnt provide gfxmod file, let patching continue assembler.resetConsts() assembler.const("INV_SIZE", 16) @@ -121,7 +119,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): assembler.const("wLinkSpawnDelay", 0xDE13) #assembler.const("HARDWARE_LINK", 1) - assembler.const("HARD_MODE", 1 if world.ladxr_settings.hardmode != "none" else 0) + assembler.const("HARD_MODE", 1 if options["hard_mode"] else 0) patches.core.cleanup(rom) patches.save.singleSaveSlot(rom) @@ -135,7 +133,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.core.easyColorDungeonAccess(rom) patches.owl.removeOwlEvents(rom) patches.enemies.fixArmosKnightAsMiniboss(rom) - patches.bank3e.addBank3E(rom, world.multi_key, world.player, player_names) + patches.bank3e.addBank3E(rom, multi_key, patch_data["player"], patch_data["other_player_names"]) patches.bank3f.addBank3F(rom) patches.bank34.addBank34(rom, item_list) patches.core.removeGhost(rom) @@ -144,19 +142,17 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.core.alwaysAllowSecretBook(rom) patches.core.injectMainLoop(rom) - from ..Options import ShuffleSmallKeys, ShuffleNightmareKeys - - if world.options.shuffle_small_keys != ShuffleSmallKeys.option_original_dungeon or\ - world.options.shuffle_nightmare_keys != ShuffleNightmareKeys.option_original_dungeon: + if options["shuffle_small_keys"] != Options.ShuffleSmallKeys.option_original_dungeon or\ + options["shuffle_nightmare_keys"] != Options.ShuffleNightmareKeys.option_original_dungeon: patches.inventory.advancedInventorySubscreen(rom) patches.inventory.moreSlots(rom) - if world.ladxr_settings.witch: - patches.witch.updateWitch(rom) + # if ladxr_settings["witch"]: + patches.witch.updateWitch(rom) patches.softlock.fixAll(rom) - if not world.ladxr_settings.rooster: + if not options["rooster"]: patches.maptweaks.tweakMap(rom) patches.maptweaks.tweakBirdKeyRoom(rom) - if world.ladxr_settings.overworld == "openmabe": + if options["overworld"] == Options.Overworld.option_open_mabe: patches.maptweaks.openMabe(rom) patches.chest.fixChests(rom) patches.shop.fixShop(rom) @@ -168,10 +164,10 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.tarin.updateTarin(rom) patches.fishingMinigame.updateFinishingMinigame(rom) patches.health.upgradeHealthContainers(rom) - if world.ladxr_settings.owlstatues in ("dungeon", "both"): - patches.owl.upgradeDungeonOwlStatues(rom) - if world.ladxr_settings.owlstatues in ("overworld", "both"): - patches.owl.upgradeOverworldOwlStatues(rom) + # if ladxr_settings["owlstatues"] in ("dungeon", "both"): + # patches.owl.upgradeDungeonOwlStatues(rom) + # if ladxr_settings["owlstatues"] in ("overworld", "both"): + # patches.owl.upgradeOverworldOwlStatues(rom) patches.goldenLeaf.fixGoldenLeaf(rom) patches.heartPiece.fixHeartPiece(rom) patches.seashell.fixSeashell(rom) @@ -180,143 +176,95 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.songs.upgradeMarin(rom) patches.songs.upgradeManbo(rom) patches.songs.upgradeMamu(rom) - patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings) - patches.bowwow.fixBowwow(rom, everywhere=world.ladxr_settings.bowwow != 'normal') - if world.ladxr_settings.bowwow != 'normal': - patches.bowwow.bowwowMapPatches(rom) + + patches.tradeSequence.patchTradeSequence(rom, options) + patches.bowwow.fixBowwow(rom, everywhere=False) + # if ladxr_settings["bowwow"] != 'normal': + # patches.bowwow.bowwowMapPatches(rom) patches.desert.desertAccess(rom) - if world.ladxr_settings.overworld == 'dungeondive': - patches.overworld.patchOverworldTilesets(rom) - patches.overworld.createDungeonOnlyOverworld(rom) - elif world.ladxr_settings.overworld == 'nodungeons': - patches.dungeon.patchNoDungeons(rom) - elif world.ladxr_settings.overworld == 'random': - patches.overworld.patchOverworldTilesets(rom) - mapgen.store_map(rom, world.ladxr_logic.world.map) + # if ladxr_settings["overworld"] == 'dungeondive': + # patches.overworld.patchOverworldTilesets(rom) + # patches.overworld.createDungeonOnlyOverworld(rom) + # elif ladxr_settings["overworld"] == 'nodungeons': + # patches.dungeon.patchNoDungeons(rom) + #elif world.ladxr_settings["overworld"] == 'random': + # patches.overworld.patchOverworldTilesets(rom) + # mapgen.store_map(rom, world.ladxr_logic.world.map) #if settings.dungeon_items == 'keysy': # patches.dungeon.removeKeyDoors(rom) # patches.reduceRNG.slowdownThreeOfAKind(rom) patches.reduceRNG.fixHorseHeads(rom) patches.bomb.onlyDropBombsWhenHaveBombs(rom) - if world.options.music_change_condition == MusicChangeCondition.option_always: + if options["music_change_condition"] == Options.MusicChangeCondition.option_always: patches.aesthetics.noSwordMusic(rom) - patches.aesthetics.reduceMessageLengths(rom, world.random) + patches.aesthetics.reduceMessageLengths(rom, random) patches.aesthetics.allowColorDungeonSpritesEverywhere(rom) - if world.ladxr_settings.music == 'random': - patches.music.randomizeMusic(rom, world.random) - elif world.ladxr_settings.music == 'off': + if options["music"] == Options.Music.option_shuffled: + patches.music.randomizeMusic(rom, random) + elif options["music"] == Options.Music.option_off: patches.music.noMusic(rom) - if world.ladxr_settings.noflash: + if options["no_flash"]: patches.aesthetics.removeFlashingLights(rom) - if world.ladxr_settings.hardmode == "oracle": + if options["hard_mode"] == Options.HardMode.option_oracle: patches.hardMode.oracleMode(rom) - elif world.ladxr_settings.hardmode == "hero": + elif options["hard_mode"] == Options.HardMode.option_hero: patches.hardMode.heroMode(rom) - elif world.ladxr_settings.hardmode == "ohko": + elif options["hard_mode"] == Options.HardMode.option_ohko: patches.hardMode.oneHitKO(rom) - if world.ladxr_settings.superweapons: - patches.weapons.patchSuperWeapons(rom) - if world.ladxr_settings.textmode == 'fast': + #if ladxr_settings["superweapons"]: + # patches.weapons.patchSuperWeapons(rom) + if options["text_mode"] == Options.TextMode.option_fast: patches.aesthetics.fastText(rom) - if world.ladxr_settings.textmode == 'none': - patches.aesthetics.fastText(rom) - patches.aesthetics.noText(rom) - if not world.ladxr_settings.nagmessages: + #if ladxr_settings["textmode"] == 'none': + # patches.aesthetics.fastText(rom) + # patches.aesthetics.noText(rom) + if not options["nag_messages"]: patches.aesthetics.removeNagMessages(rom) - if world.ladxr_settings.lowhpbeep == 'slow': + if options["low_hp_beep"] == Options.LowHpBeep.option_slow: patches.aesthetics.slowLowHPBeep(rom) - if world.ladxr_settings.lowhpbeep == 'none': + if options["low_hp_beep"] == Options.LowHpBeep.option_none: patches.aesthetics.removeLowHPBeep(rom) - if 0 <= int(world.ladxr_settings.linkspalette): - patches.aesthetics.forceLinksPalette(rom, int(world.ladxr_settings.linkspalette)) + if 0 <= options["link_palette"]: + patches.aesthetics.forceLinksPalette(rom, options["link_palette"]) if args.romdebugmode: # The default rom has this build in, just need to set a flag and we get this save. rom.patch(0, 0x0003, "00", "01") # Patch the sword check on the shopkeeper turning around. - if world.ladxr_settings.steal == 'never': - rom.patch(4, 0x36F9, "FA4EDB", "3E0000") - elif world.ladxr_settings.steal == 'always': - rom.patch(4, 0x36F9, "FA4EDB", "3E0100") + #if ladxr_settings["steal"] == 'never': + # rom.patch(4, 0x36F9, "FA4EDB", "3E0000") + #elif ladxr_settings["steal"] == 'always': + # rom.patch(4, 0x36F9, "FA4EDB", "3E0100") - if world.ladxr_settings.hpmode == 'inverted': - patches.health.setStartHealth(rom, 9) - elif world.ladxr_settings.hpmode == '1': - patches.health.setStartHealth(rom, 1) + #if ladxr_settings["hpmode"] == 'inverted': + # patches.health.setStartHealth(rom, 9) + #elif ladxr_settings["hpmode"] == '1': + # patches.health.setStartHealth(rom, 1) patches.inventory.songSelectAfterOcarinaSelect(rom) - if world.ladxr_settings.quickswap == 'a': + if options["quickswap"] == 'a': patches.core.quickswap(rom, 1) - elif world.ladxr_settings.quickswap == 'b': + elif options["quickswap"] == 'b': patches.core.quickswap(rom, 0) - patches.core.addBootsControls(rom, world.options.boots_controls) + patches.core.addBootsControls(rom, options["boots_controls"]) + random.seed(patch_data["seed"] + patch_data["player"]) + hints.addHints(rom, random, patch_data["hint_texts"]) - world_setup = world.ladxr_logic.world_setup - - JUNK_HINT = 0.33 - RANDOM_HINT= 0.66 - # USEFUL_HINT = 1.0 - # TODO: filter events, filter unshuffled keys - all_items = world.multiworld.get_items() - our_items = [item for item in all_items - if item.player == world.player - and item.location - and item.code is not None - and item.location.show_in_spoiler] - our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification] - - def gen_hint(): - if not world.options.in_game_hints: - return 'Hints are disabled!' - chance = world.random.uniform(0, 1) - if chance < JUNK_HINT: - return None - elif chance < RANDOM_HINT: - location = world.random.choice(our_items).location - else: # USEFUL_HINT - location = world.random.choice(our_useful_items).location - - if location.item.player == world.player: - name = "Your" - else: - name = f"{world.multiworld.player_name[location.item.player]}'s" - # filter out { and } since they cause issues with string.format later on - name = name.replace("{", "").replace("}", "") - - if isinstance(location, LinksAwakeningLocation): - location_name = location.ladxr_item.metadata.name - else: - location_name = location.name - - hint = f"{name} {location.item.name} is at {location_name}" - if location.player != world.player: - # filter out { and } since they cause issues with string.format later on - player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "") - hint += f" in {player_name}'s world" - - # Cap hint size at 85 - # Realistically we could go bigger but let's be safe instead - hint = hint[:85] - - return hint - - hints.addHints(rom, world.random, gen_hint) - - if world_setup.goal == "raft": + if patch_data["world_setup"]["goal"] == "raft": patches.goal.setRaftGoal(rom) - elif world_setup.goal in ("bingo", "bingo-full"): - patches.bingo.setBingoGoal(rom, world_setup.bingo_goals, world_setup.goal) - elif world_setup.goal == "seashells": + elif patch_data["world_setup"]["goal"] in ("bingo", "bingo-full"): + patches.bingo.setBingoGoal(rom, patch_data["world_setup"]["bingo_goals"], patch_data["world_setup"]["goal"]) + elif patch_data["world_setup"]["goal"] == "seashells": patches.goal.setSeashellGoal(rom, 20) else: - patches.goal.setRequiredInstrumentCount(rom, world_setup.goal) + patches.goal.setRequiredInstrumentCount(rom, patch_data["world_setup"]["goal"]) # Patch the generated logic into the rom - patches.chest.setMultiChest(rom, world_setup.multichest) - if world.ladxr_settings.overworld not in {"dungeondive", "random"}: - patches.entrances.changeEntrances(rom, world_setup.entrance_mapping) + patches.chest.setMultiChest(rom, patch_data["world_setup"]["multichest"]) + #if ladxr_settings["overworld"] not in {"dungeondive", "random"}: + patches.entrances.changeEntrances(rom, patch_data["world_setup"]["entrance_mapping"]) for spot in item_list: if spot.item and spot.item.startswith("*"): spot.item = spot.item[1:] @@ -327,23 +275,22 @@ def generateRom(args, world: "LinksAwakeningWorld"): # There are only 101 player name slots (99 + "The Server" + "another world"), so don't use more than that mw = 100 spot.patch(rom, spot.item, multiworld=mw) - patches.enemies.changeBosses(rom, world_setup.boss_mapping) - patches.enemies.changeMiniBosses(rom, world_setup.miniboss_mapping) + patches.enemies.changeBosses(rom, patch_data["world_setup"]["boss_mapping"]) + patches.enemies.changeMiniBosses(rom, patch_data["world_setup"]["miniboss_mapping"]) if not args.romdebugmode: patches.core.addFrameCounter(rom, len(item_list)) patches.core.warpHome(rom) # Needs to be done after setting the start location. - patches.titleScreen.setRomInfo(rom, world.multi_key, world.multiworld.seed_name, world.ladxr_settings, - world.player_name, world.player) - if world.options.ap_title_screen: + patches.titleScreen.setRomInfo(rom, patch_data) + if options["ap_title_screen"]: patches.titleScreen.setTitleGraphics(rom) patches.endscreen.updateEndScreen(rom) patches.aesthetics.updateSpriteData(rom) if args.doubletrouble: patches.enemies.doubleTrouble(rom) - if world.options.text_shuffle: + if options["text_shuffle"]: excluded_ids = [ # Overworld owl statues 0x1B6, 0x1B7, 0x1B8, 0x1B9, 0x1BA, 0x1BB, 0x1BC, 0x1BD, 0x1BE, 0x22D, @@ -388,6 +335,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): excluded_texts = [ rom.texts[excluded_id] for excluded_id in excluded_ids] buckets = defaultdict(list) # For each ROM bank, shuffle text within the bank + random.seed(patch_data["seed"] + patch_data["player"]) for n, data in enumerate(rom.texts._PointerTable__data): # Don't muck up which text boxes are questions and which are statements if type(data) != int and data and data != b'\xFF' and data not in excluded_texts: @@ -395,20 +343,20 @@ def generateRom(args, world: "LinksAwakeningWorld"): for bucket in buckets.values(): # For each bucket, make a copy and shuffle shuffled = bucket.copy() - world.random.shuffle(shuffled) + random.shuffle(shuffled) # Then put new text in for bucket_idx, (orig_idx, data) in enumerate(bucket): rom.texts[shuffled[bucket_idx][0]] = data - if world.options.trendy_game != TrendyGame.option_normal: + if options["trendy_game"] != Options.TrendyGame.option_normal: # TODO: if 0 or 4, 5, remove inaccurate conveyor tiles room_editor = RoomEditor(rom, 0x2A0) - if world.options.trendy_game == TrendyGame.option_easy: + if options["trendy_game"] == Options.TrendyGame.option_easy: # Set physics flag on all objects for i in range(0, 6): rom.banks[0x4][0x6F1E + i -0x4000] = 0x4 @@ -419,7 +367,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): # Add new conveyor to "push" yoshi (it's only a visual) room_editor.objects.append(Object(5, 3, 0xD0)) - if world.options.trendy_game >= TrendyGame.option_harder: + if options["trendy_game"] >= Options.TrendyGame.option_harder: """ Data_004_76A0:: db $FC, $00, $04, $00, $00 @@ -428,17 +376,18 @@ def generateRom(args, world: "LinksAwakeningWorld"): db $00, $04, $00, $FC, $00 """ speeds = { - TrendyGame.option_harder: (3, 8), - TrendyGame.option_hardest: (3, 8), - TrendyGame.option_impossible: (3, 16), + Options.TrendyGame.option_harder: (3, 8), + Options.TrendyGame.option_hardest: (3, 8), + Options.TrendyGame.option_impossible: (3, 16), } def speed(): - return world.random.randint(*speeds[world.options.trendy_game]) + random.seed(patch_data["seed"] + patch_data["player"]) + return random.randint(*speeds[options["trendy_game"]]) rom.banks[0x4][0x76A0-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A2-0x4000] = speed() rom.banks[0x4][0x76A6-0x4000] = speed() rom.banks[0x4][0x76A8-0x4000] = 0xFF - speed() - if world.options.trendy_game >= TrendyGame.option_hardest: + if options["trendy_game"] >= Options.TrendyGame.option_hardest: rom.banks[0x4][0x76A1-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A3-0x4000] = speed() rom.banks[0x4][0x76A5-0x4000] = speed() @@ -462,11 +411,11 @@ def generateRom(args, world: "LinksAwakeningWorld"): for channel in range(3): color[channel] = color[channel] * 31 // 0xbc - if world.options.warps != Warps.option_vanilla: - patches.core.addWarpImprovements(rom, world.options.warps == Warps.option_improved_additional) + if options["warps"] != Options.Warps.option_vanilla: + patches.core.addWarpImprovements(rom, options["warps"] == Options.Warps.option_improved_additional) - palette = world.options.palette - if palette != Palette.option_normal: + palette = options["palette"] + if palette != Options.Palette.option_normal: ranges = { # Object palettes # Overworld palettes @@ -496,22 +445,22 @@ def generateRom(args, world: "LinksAwakeningWorld"): r,g,b = bin_to_rgb(packed) # 1 bit - if palette == Palette.option_1bit: + if palette == Options.Palette.option_1bit: r &= 0b10000 g &= 0b10000 b &= 0b10000 # 2 bit - elif palette == Palette.option_1bit: + elif palette == Options.Palette.option_1bit: r &= 0b11000 g &= 0b11000 b &= 0b11000 # Invert - elif palette == Palette.option_inverted: + elif palette == Options.Palette.option_inverted: r = 31 - r g = 31 - g b = 31 - b # Pink - elif palette == Palette.option_pink: + elif palette == Options.Palette.option_pink: r = r // 2 r += 16 r = int(r) @@ -520,7 +469,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): b += 16 b = int(b) b = clamp(b, 0, 0x1F) - elif palette == Palette.option_greyscale: + elif palette == Options.Palette.option_greyscale: # gray=int(0.299*r+0.587*g+0.114*b) gray = (r + g + b) // 3 r = g = b = gray @@ -531,10 +480,10 @@ def generateRom(args, world: "LinksAwakeningWorld"): SEED_LOCATION = 0x0134 # Patch over the title - assert(len(world.multi_key) == 12) - rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(world.multi_key)) + assert(len(multi_key) == 12) + rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(multi_key)) for pymod in pymods: pymod.postPatch(rom) - return rom + return rom.save() diff --git a/worlds/ladx/LADXR/hints.py b/worlds/ladx/LADXR/hints.py index aa7854889b..6f9f3e60f4 100644 --- a/worlds/ladx/LADXR/hints.py +++ b/worlds/ladx/LADXR/hints.py @@ -1,5 +1,7 @@ from .locations.items import * from .utils import formatText +from BaseClasses import ItemClassification +from ..Locations import LinksAwakeningLocation hint_text_ids = [ @@ -49,14 +51,64 @@ useless_hint = [ ] -def addHints(rom, rnd, hint_generator): +def addHints(rom, rnd, hint_texts): + hint_texts_copy = hint_texts.copy() text_ids = hint_text_ids.copy() rnd.shuffle(text_ids) for text_id in text_ids: - hint = hint_generator() + hint = hint_texts_copy.pop() if not hint: hint = rnd.choice(hints).format(*rnd.choice(useless_hint)) rom.texts[text_id] = formatText(hint) for text_id in range(0x200, 0x20C, 2): rom.texts[text_id] = formatText("Read this book?", ask="YES NO") + + +def generate_hint_texts(world): + JUNK_HINT = 0.33 + RANDOM_HINT= 0.66 + # USEFUL_HINT = 1.0 + # TODO: filter events, filter unshuffled keys + all_items = world.multiworld.get_items() + our_items = [item for item in all_items + if item.player == world.player + and item.location + and item.code is not None + and item.location.show_in_spoiler] + our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification] + hint_texts = [] + def gen_hint(): + chance = world.random.uniform(0, 1) + if chance < JUNK_HINT: + return None + elif chance < RANDOM_HINT: + location = world.random.choice(our_items).location + else: # USEFUL_HINT + location = world.random.choice(our_useful_items).location + + if location.item.player == world.player: + name = "Your" + else: + name = f"{world.multiworld.player_name[location.item.player]}'s" + # filter out { and } since they cause issues with string.format later on + name = name.replace("{", "").replace("}", "") + + if isinstance(location, LinksAwakeningLocation): + location_name = location.ladxr_item.metadata.name + else: + location_name = location.name + + hint = f"{name} {location.item} is at {location_name}" + if location.player != world.player: + # filter out { and } since they cause issues with string.format later on + player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "") + hint += f" in {player_name}'s world" + + # Cap hint size at 85 + # Realistically we could go bigger but let's be safe instead + hint = hint[:85] + return hint + for _ in hint_text_ids: + hint_texts.append(gen_hint()) + return hint_texts diff --git a/worlds/ladx/LADXR/patches/aesthetics.py b/worlds/ladx/LADXR/patches/aesthetics.py index 6ca7d3d973..2c9c818687 100644 --- a/worlds/ladx/LADXR/patches/aesthetics.py +++ b/worlds/ladx/LADXR/patches/aesthetics.py @@ -180,9 +180,10 @@ def noText(rom): def reduceMessageLengths(rom, rnd): # Into text from Marin. Got to go fast, so less text. (This intro text is very long) - lines = pkgutil.get_data(__name__, "marin.txt").decode("unicode_escape").splitlines() - lines = [l for l in lines if l.strip()] - rom.texts[0x01] = formatText(rnd.choice(lines).strip()) + lines = pkgutil.get_data(__name__, "marin.txt").splitlines(keepends=True) + while lines and lines[-1].strip() == b'': + lines.pop(-1) + rom.texts[0x01] = formatText(rnd.choice(lines).strip().decode("unicode_escape")) # Reduce length of a bunch of common texts rom.texts[0xEA] = formatText("You've got a Guardian Acorn!") diff --git a/worlds/ladx/LADXR/patches/core.py b/worlds/ladx/LADXR/patches/core.py index d9fcd62e30..10e85f9dc5 100644 --- a/worlds/ladx/LADXR/patches/core.py +++ b/worlds/ladx/LADXR/patches/core.py @@ -541,7 +541,7 @@ OAMData: rom.banks[0x38][0x1400+n*0x20:0x1410+n*0x20] = utils.createTileData(gfx_high) rom.banks[0x38][0x1410+n*0x20:0x1420+n*0x20] = utils.createTileData(gfx_low) -def addBootsControls(rom, boots_controls: BootsControls): +def addBootsControls(rom, boots_controls: int): if boots_controls == BootsControls.option_vanilla: return consts = { @@ -578,7 +578,7 @@ def addBootsControls(rom, boots_controls: BootsControls): jr z, .yesBoots ld a, [hl] """ - }[boots_controls.value] + }[boots_controls] # The new code fits exactly within Nintendo's poorly space optimzied code while having more features boots_code = assembler.ASM(""" diff --git a/worlds/ladx/LADXR/patches/enemies.py b/worlds/ladx/LADXR/patches/enemies.py index f5e1df1313..29322918f2 100644 --- a/worlds/ladx/LADXR/patches/enemies.py +++ b/worlds/ladx/LADXR/patches/enemies.py @@ -42,7 +42,7 @@ MINIBOSS_ENTITIES = { "ARMOS_KNIGHT": [(4, 3, 0x88)], } MINIBOSS_ROOMS = { - 0: 0x111, 1: 0x128, 2: 0x145, 3: 0x164, 4: 0x193, 5: 0x1C5, 6: 0x228, 7: 0x23F, + "0": 0x111, "1": 0x128, "2": 0x145, "3": 0x164, "4": 0x193, "5": 0x1C5, "6": 0x228, "7": 0x23F, "c1": 0x30C, "c2": 0x303, "moblin_cave": 0x2E1, "armos_temple": 0x27F, diff --git a/worlds/ladx/LADXR/patches/marin.txt b/worlds/ladx/LADXR/patches/marin.txt index 3634014afe..782f4129ce 100644 --- a/worlds/ladx/LADXR/patches/marin.txt +++ b/worlds/ladx/LADXR/patches/marin.txt @@ -220,7 +220,6 @@ To this day I still don't know if we inconvenienced the Mad Batter or not. Oh, hi ##### People forgot I was playable in Hyrule Warriors Join our Discord. Or else. -Also try Minecraft! I see you're finally awake... OwO This is Todd Howard, and today I'm pleased to announce... The Elder Scrolls V: Skyrim for the Nintendo Game Boy Color! @@ -256,7 +255,6 @@ Try Bumper Stickers! Try Castlevania 64! Try Celeste 64! Try ChecksFinder! -Try Clique! Try Dark Souls III! Try DLCQuest! Try Donkey Kong Country 3! @@ -269,6 +267,7 @@ Try A Hat in Time! Try Heretic! Try Hollow Knight! Try Hylics 2! +Try Jak and Daxter: The Precursor Legacy! Try Kingdom Hearts 2! Try Kirby's Dream Land 3! Try Landstalker - The Treasures of King Nole! @@ -281,7 +280,6 @@ Try Mario & Luigi Superstar Saga! Try MegaMan Battle Network 3! Try Meritous! Try The Messenger! -Try Minecraft! Try Muse Dash! Try Noita! Try Ocarina of Time! @@ -290,11 +288,10 @@ Try Pokemon Emerald! Try Pokemon Red and Blue! Try Raft! Try Risk of Rain 2! -Try Rogue Legacy! Try Secret of Evermore! +Try shapez! Try Shivers! Try A Short Hike! -Try Slay the Spire! Try SMZ3! Try Sonic Adventure 2 Battle! Try Starcraft 2! @@ -302,6 +299,7 @@ Try Stardew Valley! Try Subnautica! Try Sudoku! Try Super Mario 64! +Try Super Mario Land 2: 6 Golden Coins! Try Super Mario World! Try Super Metroid! Try Terraria! @@ -314,7 +312,6 @@ Try The Witness! Try Yoshi's Island! Try Yu-Gi-Oh! 2006! Try Zillion! -Try Zork Grand Inquisitor! Try Old School Runescape! Try Kingdom Hearts! Try Mega Man 2! @@ -371,7 +368,6 @@ Have they added Among Us to AP yet? Every copy of LADX is personalized, David. Looks like you're going on A Short Hike. Bring back feathers please? Functioning Brain is at...\nWait. This isn't Witness. Wrong game, sorry. -Don't forget to check your Clique!\nIf, y'know, you have one. No pressure... :3 Sorry ######, but your progression item is in another world. &newgames\n&oldgames diff --git a/worlds/ladx/LADXR/patches/titleScreen.py b/worlds/ladx/LADXR/patches/titleScreen.py index 3a4dade218..d986a570ef 100644 --- a/worlds/ladx/LADXR/patches/titleScreen.py +++ b/worlds/ladx/LADXR/patches/titleScreen.py @@ -1,7 +1,6 @@ from ..backgroundEditor import BackgroundEditor from .aesthetics import rgb_to_bin, bin_to_rgb, prepatch import copy -import pkgutil CHAR_MAP = {'z': 0x3E, '-': 0x3F, '.': 0x39, ':': 0x42, '?': 0x3C, '!': 0x3D} def _encode(s): @@ -18,17 +17,18 @@ def _encode(s): return result -def setRomInfo(rom, seed, seed_name, settings, player_name, player_id): +def setRomInfo(rom, patch_data): + seed_name = patch_data["seed_name"] try: - seednr = int(seed, 16) + seednr = int(patch_data["seed"], 16) except: import hashlib - seednr = int(hashlib.md5(seed).hexdigest(), 16) + seednr = int(hashlib.md5(str(patch_data["seed"]).encode()).hexdigest(), 16) - if settings.race: + if patch_data["is_race"]: seed_name = "Race" - if isinstance(settings.race, str): - seed_name += " " + settings.race + if isinstance(patch_data["is_race"], str): + seed_name += " " + patch_data["is_race"] rom.patch(0x00, 0x07, "00", "01") else: rom.patch(0x00, 0x07, "00", "52") @@ -37,7 +37,7 @@ def setRomInfo(rom, seed, seed_name, settings, player_name, player_id): #line_2_hex = _encode(seed[16:]) BASE_DRAWING_AREA = 0x98a0 LINE_WIDTH = 0x20 - player_id_text = f"Player {player_id}:" + player_id_text = f"Player {patch_data['player']}:" for n in (3, 4): be = BackgroundEditor(rom, n) ba = BackgroundEditor(rom, n, attributes=True) @@ -45,9 +45,9 @@ def setRomInfo(rom, seed, seed_name, settings, player_name, player_id): for n, v in enumerate(_encode(player_id_text)): be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 5 + 2 + n] = v ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 5 + 2 + n] = 0x00 - for n, v in enumerate(_encode(player_name)): - be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(player_name) + n] = v - ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(player_name) + n] = 0x00 + for n, v in enumerate(_encode(patch_data['player_name'])): + be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(patch_data['player_name']) + n] = v + ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(patch_data['player_name']) + n] = 0x00 for n, v in enumerate(line_1_hex): be.tiles[0x9a20 + n] = v ba.tiles[0x9a20 + n] = 0x00 diff --git a/worlds/ladx/LADXR/patches/tradeSequence.py b/worlds/ladx/LADXR/patches/tradeSequence.py index 0eb46ae23a..ef6f635d45 100644 --- a/worlds/ladx/LADXR/patches/tradeSequence.py +++ b/worlds/ladx/LADXR/patches/tradeSequence.py @@ -387,7 +387,7 @@ def patchVarious(rom, settings): # Boomerang trade guy # if settings.boomerang not in {'trade', 'gift'} or settings.overworld in {'normal', 'nodungeons'}: - if settings.tradequest: + if settings["tradequest"]: # Update magnifier checks rom.patch(0x19, 0x05EC, ASM("ld a, [wTradeSequenceItem]\ncp $0E\njp nz, $7E61"), ASM("ld a, [wTradeSequenceItem2]\nand $20\njp z, $7E61")) # show the guy rom.patch(0x00, 0x3199, ASM("ld a, [wTradeSequenceItem]\ncp $0E\njr nz, $06"), ASM("ld a, [wTradeSequenceItem2]\nand $20\njr z, $06")) # load the proper room layout diff --git a/worlds/ladx/LADXR/rom.py b/worlds/ladx/LADXR/rom.py index 21969f4ab4..54d8f02916 100644 --- a/worlds/ladx/LADXR/rom.py +++ b/worlds/ladx/LADXR/rom.py @@ -7,9 +7,7 @@ h2b = binascii.unhexlify class ROM: - def __init__(self, filename, patches=None): - data = open(Utils.user_path(filename), "rb").read() - + def __init__(self, data, patches=None): if patches: for patch in patches: data = bsdiff4.patch(data, patch) @@ -64,18 +62,10 @@ class ROM: self.banks[0][0x14E] = checksum >> 8 self.banks[0][0x14F] = checksum & 0xFF - def save(self, file, *, name=None): + def save(self): # don't pass the name to fixHeader self.fixHeader() - if isinstance(file, str): - f = open(file, "wb") - for bank in self.banks: - f.write(bank) - f.close() - print("Saved:", file) - else: - for bank in self.banks: - file.write(bank) + return b"".join(self.banks) def readHexSeed(self): return self.banks[0x3E][0x2F00:0x2F10].hex().upper() diff --git a/worlds/ladx/LADXR/romTables.py b/worlds/ladx/LADXR/romTables.py index 3192443685..51acacc31c 100644 --- a/worlds/ladx/LADXR/romTables.py +++ b/worlds/ladx/LADXR/romTables.py @@ -181,8 +181,8 @@ class IndoorRoomSpriteData(PointerTable): class ROMWithTables(ROM): - def __init__(self, filename, patches=None): - super().__init__(filename, patches) + def __init__(self, data, patches=None): + super().__init__(data, patches) # Ability to patch any text in the game with different text self.texts = Texts(self) @@ -203,7 +203,7 @@ class ROMWithTables(ROM): self.itemNames = {} - def save(self, filename, *, name=None): + def save(self): # Assert special handling of bank 9 expansion is fine for i in range(0x3d42, 0x4000): assert self.banks[9][i] == 0, self.banks[9][i] @@ -221,4 +221,4 @@ class ROMWithTables(ROM): self.room_sprite_data_indoor.store(self) self.background_tiles.store(self) self.background_attributes.store(self) - super().save(filename, name=name) + return super().save() diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 7ea7df3659..8abfb0fbc9 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -425,46 +425,11 @@ class TrendyGame(Choice): default = option_normal -class GfxMod(FreeText, LADXROption): +class GfxMod(DefaultOffToggle): """ - Sets the sprite for link, among other things - The option should be the same name as a with sprite (and optional name) file in data/sprites/ladx + If enabled, the patcher will prompt the user for a modification file to change sprites in the game and optionally some text. """ display_name = "GFX Modification" - ladxr_name = "gfxmod" - normal = '' - default = 'Link' - - __spriteDir: str = Utils.local_path(os.path.join('data', 'sprites', 'ladx')) - __spriteFiles: typing.DefaultDict[str, typing.List[str]] = defaultdict(list) - - extensions = [".bin", ".bdiff", ".png", ".bmp"] - - for file in os.listdir(__spriteDir): - name, extension = os.path.splitext(file) - if extension in extensions: - __spriteFiles[name].append(file) - - def __init__(self, value: str): - super().__init__(value) - - def verify(self, world, player_name: str, plando_options) -> None: - if self.value == "Link" or self.value in GfxMod.__spriteFiles: - return - raise Exception( - f"LADX Sprite '{self.value}' not found. Possible sprites are: {['Link'] + list(GfxMod.__spriteFiles.keys())}") - - def to_ladxr_option(self, all_options): - if self.value == -1 or self.value == "Link": - return None, None - - assert self.value in GfxMod.__spriteFiles - - if len(GfxMod.__spriteFiles[self.value]) > 1: - logger.warning( - f"{self.value} does not uniquely identify a file. Possible matches: {GfxMod.__spriteFiles[self.value]}. Using {GfxMod.__spriteFiles[self.value][0]}") - - return self.ladxr_name, self.__spriteDir + "/" + GfxMod.__spriteFiles[self.value][0] class Palette(Choice): diff --git a/worlds/ladx/Rom.py b/worlds/ladx/Rom.py index 8ae1fac0fa..969215a5e4 100644 --- a/worlds/ladx/Rom.py +++ b/worlds/ladx/Rom.py @@ -3,19 +3,112 @@ import worlds.Files import hashlib import Utils import os +import json +import pkgutil +import bsdiff4 +import binascii +import pickle +from typing import TYPE_CHECKING +from .Common import * +from .LADXR import generator +from .LADXR.main import get_parser +from .LADXR.hints import generate_hint_texts +from .LADXR.locations.keyLocation import KeyLocation LADX_HASH = "07c211479386825042efb4ad31bb525f" -class LADXDeltaPatch(worlds.Files.APDeltaPatch): +if TYPE_CHECKING: + from . import LinksAwakeningWorld + + +class LADXPatchExtensions(worlds.Files.APPatchExtension): + game = LINKS_AWAKENING + + @staticmethod + def generate_rom(caller: worlds.Files.APProcedurePatch, rom: bytes, data_file: str) -> bytes: + patch_data = json.loads(caller.get_file(data_file).decode("utf-8")) + # TODO local option overrides + rom_name = get_base_rom_path() + out_name = f"{patch_data['out_base']}{caller.result_file_ending}" + parser = get_parser() + args = parser.parse_args([rom_name, "-o", out_name, "--dump"]) + return generator.generateRom(rom, args, patch_data) + + @staticmethod + def patch_title_screen(caller: worlds.Files.APProcedurePatch, rom: bytes, data_file: str) -> bytes: + patch_data = json.loads(caller.get_file(data_file).decode("utf-8")) + if patch_data["options"]["ap_title_screen"]: + return bsdiff4.patch(rom, pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4")) + return rom + +class LADXProcedurePatch(worlds.Files.APProcedurePatch): hash = LADX_HASH - game = "Links Awakening DX" - patch_file_ending = ".apladx" + game = LINKS_AWAKENING + patch_file_ending: str = ".apladx" result_file_ending: str = ".gbc" + procedure = [ + ("generate_rom", ["data.json"]), + ("patch_title_screen", ["data.json"]) + ] + @classmethod def get_source_data(cls) -> bytes: return get_base_rom_bytes() +def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch): + item_list = pickle.dumps([item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)]) + data_dict = { + "out_base": world.multiworld.get_out_file_name_base(patch.player), + "is_race": world.multiworld.is_race, + "seed": world.multiworld.seed, + "seed_name": world.multiworld.seed_name, + "multi_key": binascii.hexlify(world.multi_key).decode(), + "player": patch.player, + "player_name": patch.player_name, + "other_player_names": list(world.multiworld.player_name.values()), + "item_list": binascii.hexlify(item_list).decode(), + "hint_texts": generate_hint_texts(world), + "world_setup": { + "goal": world.ladxr_logic.world_setup.goal, + "bingo_goals": world.ladxr_logic.world_setup.bingo_goals, + "multichest": world.ladxr_logic.world_setup.multichest, + "entrance_mapping": world.ladxr_logic.world_setup.entrance_mapping, + "boss_mapping": world.ladxr_logic.world_setup.boss_mapping, + "miniboss_mapping": world.ladxr_logic.world_setup.miniboss_mapping, + }, + "options": world.options.as_dict( + "tradequest", + "rooster", + "experimental_dungeon_shuffle", + "experimental_entrance_shuffle", + "goal", + "instrument_count", + "link_palette", + "warps", + "trendy_game", + "gfxmod", + "palette", + "text_shuffle", + "shuffle_nightmare_keys", + "shuffle_small_keys", + "music", + "music_change_condition", + "nag_messages", + "ap_title_screen", + "boots_controls", + # "stealing", + "quickswap", + "hard_mode", + "low_hp_beep", + "text_mode", + "no_flash", + "overworld", + ), + } + patch.write_file("data.json", json.dumps(data_dict).encode('utf-8')) + + def get_base_rom_bytes(file_name: str = "") -> bytes: base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None) if not base_rom_bytes: diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 78ae1ce8ad..f17b602ed1 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -1,16 +1,12 @@ import binascii import dataclasses import os -import pkgutil -import tempfile import typing import logging import re -import bsdiff4 - import settings -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial, MultiWorld +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial from Fill import fill_restrictive from worlds.AutoWorld import WebWorld, World from .Common import * @@ -18,19 +14,17 @@ from . import ItemIconGuessing from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData, ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name, links_awakening_item_name_groups) -from .LADXR import generator from .LADXR.itempool import ItemPool as LADXRItemPool from .LADXR.locations.constants import CHEST_ITEMS from .LADXR.locations.instrument import Instrument from .LADXR.logic import Logic as LADXRLogic -from .LADXR.main import get_parser from .LADXR.settings import Settings as LADXRSettings from .LADXR.worldSetup import WorldSetup as LADXRWorldSetup from .Locations import (LinksAwakeningLocation, LinksAwakeningRegion, create_regions_from_ladxr, get_locations_to_id, links_awakening_location_name_groups) from .Options import DungeonItemShuffle, ShuffleInstruments, LinksAwakeningOptions, ladx_option_groups -from .Rom import LADXDeltaPatch, get_base_rom_path +from .Rom import LADXProcedurePatch, write_patch_data DEVELOPER_MODE = False @@ -40,7 +34,7 @@ class LinksAwakeningSettings(settings.Group): """File name of the Link's Awakening DX rom""" copy_to = "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc" description = "LADX ROM File" - md5s = [LADXDeltaPatch.hash] + md5s = [LADXProcedurePatch.hash] class RomStart(str): """ @@ -57,8 +51,16 @@ class LinksAwakeningSettings(settings.Group): class DisplayMsgs(settings.Bool): """Display message inside of Bizhawk""" + class GfxModFile(settings.FilePath): + """ + Gfxmod file, get it from upstream: https://github.com/daid/LADXR/tree/master/gfx + Only .bin or .bdiff files + The same directory will be checked for a matching text modification file + """ + rom_file: RomFile = RomFile(RomFile.copy_to) rom_start: typing.Union[RomStart, bool] = True + gfx_mod_file: GfxModFile = GfxModFile() class LinksAwakeningWebWorld(WebWorld): tutorials = [Tutorial( @@ -179,10 +181,10 @@ class LinksAwakeningWorld(World): assert(start) - menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld) + menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld) menu_region.exits = [Entrance(self.player, "Start Game", menu_region)] menu_region.exits[0].connect(start) - + self.multiworld.regions.append(menu_region) # Place RAFT, other access events @@ -190,14 +192,14 @@ class LinksAwakeningWorld(World): for loc in region.locations: if loc.address is None: loc.place_locked_item(self.create_event(loc.ladxr_item.event)) - + # Connect Windfish -> Victory windfish = self.multiworld.get_region("Windfish", self.player) l = Location(self.player, "Windfish", parent=windfish) windfish.locations = [l] - + l.place_locked_item(self.create_event("An Alarm Clock")) - + self.multiworld.completion_condition[self.player] = lambda state: state.has("An Alarm Clock", player=self.player) def create_item(self, item_name: str): @@ -279,8 +281,8 @@ class LinksAwakeningWorld(World): event_location = Location(self.player, "Can Play Trendy Game", parent=trendy_region) trendy_region.locations.insert(0, event_location) event_location.place_locked_item(self.create_event("Can Play Trendy Game")) - - self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []] + + self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []] for r in self.multiworld.get_regions(self.player): # Set aside dungeon locations if r.dungeon_index: @@ -335,7 +337,9 @@ class LinksAwakeningWorld(World): start_item = next((item for item in start_items if opens_new_regions(item)), None) if start_item: - itempool.remove(start_item) + # Make sure we're removing the same copy of the item that we're placing + # (.remove checks __eq__, which could be a different copy, so we find the first index and use .pop) + start_item = itempool.pop(itempool.index(start_item)) start_loc.place_locked_item(start_item) else: logging.getLogger("Link's Awakening Logger").warning(f"No {self.options.tarins_gift.current_option_name} available for Tarin's Gift.") @@ -352,7 +356,7 @@ class LinksAwakeningWorld(World): # set containing the list of all possible dungeon locations for the player all_dungeon_locs = set() - + # Do dungeon specific things for dungeon_index in range(0, 9): # set up allow-list for dungeon specific items @@ -365,7 +369,7 @@ class LinksAwakeningWorld(World): # ...also set the rules for the dungeon for location in locs: orig_rule = location.item_rule - # If an item is about to be placed on a dungeon location, it can go there iff + # If an item is about to be placed on a dungeon location, it can go there iff # 1. it fits the general rules for that location (probably 'return True' for most places) # 2. Either # 2a. it's not a restricted dungeon item @@ -419,7 +423,7 @@ class LinksAwakeningWorld(World): partial_all_state.sweep_for_advancements() fill_restrictive(self.multiworld, partial_all_state, all_dungeon_locs_to_fill, all_dungeon_items_to_fill, lock=True, single_player_placement=True, allow_partial=False) - + name_cache = {} # Tries to associate an icon from another game with an icon we have @@ -456,22 +460,16 @@ class LinksAwakeningWorld(World): for name in possibles: if name in self.name_cache: return self.name_cache[name] - + return "TRADING_ITEM_LETTER" - @classmethod - def stage_assert_generate(cls, multiworld: MultiWorld): - rom_file = get_base_rom_path() - if not os.path.exists(rom_file): - raise FileNotFoundError(rom_file) - def generate_output(self, output_directory: str): # copy items back to locations for r in self.multiworld.get_regions(self.player): for loc in r.locations: if isinstance(loc, LinksAwakeningLocation): assert(loc.item) - + # If we're a links awakening item, just use the item if isinstance(loc.item, LinksAwakeningItem): loc.ladxr_item.item = loc.item.item_data.ladxr_id @@ -497,31 +495,13 @@ class LinksAwakeningWorld(World): # Kind of kludge, make it possible for the location to differentiate between local and remote items loc.ladxr_item.location_owner = self.player - rom_name = Rom.get_base_rom_path() - out_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.player_name}.gbc" - out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.gbc") + + patch = LADXProcedurePatch(player=self.player, player_name=self.player_name) + write_patch_data(self, patch) + out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}" + f"{patch.patch_file_ending}") - parser = get_parser() - args = parser.parse_args([rom_name, "-o", out_name, "--dump"]) - - rom = generator.generateRom(args, self) - - with open(out_path, "wb") as handle: - rom.save(handle, name="LADXR") - - # Write title screen after everything else is done - full gfxmods may stomp over the egg tiles - if self.options.ap_title_screen: - with tempfile.NamedTemporaryFile(delete=False) as title_patch: - title_patch.write(pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4")) - - bsdiff4.file_patch_inplace(out_path, title_patch.name) - os.unlink(title_patch.name) - - patch = LADXDeltaPatch(os.path.splitext(out_path)[0]+LADXDeltaPatch.patch_file_ending, player=self.player, - player_name=self.player_name, patched_path=out_path) - patch.write() - if not DEVELOPER_MODE: - os.unlink(out_path) + patch.write(out_path) def generate_multi_key(self): return bytearray(self.random.getrandbits(8) for _ in range(10)) + self.player.to_bytes(2, 'big') diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index 6410ffea3b..4c41f3236f 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -4956,10 +4956,16 @@ Outside The Initiated: room: Art Gallery door: Exit - The Bearer (East): True - The Bearer (North): True - The Bearer (South): True - The Bearer (West): True + The Bearer (East): + static_painting: True + The Bearer (North): + static_painting: True + The Bearer (South): + static_painting: True + The Bearer (West): + - static_painting: True + - room: The Bearer (West) + door: Side Area Shortcut Roof: True panels: Achievement: @@ -5053,7 +5059,8 @@ - MIDDLE The Bearer (East): entrances: - Cross Tower (East): True + Cross Tower (East): + static_painting: True Bearer Side Area: door: Side Area Access Roof: True @@ -5084,7 +5091,8 @@ panel: SPACE The Bearer (North): entrances: - Cross Tower (East): True + Cross Tower (North): + static_painting: True Roof: True panels: SILENT (1): @@ -5128,7 +5136,8 @@ panel: POTS The Bearer (South): entrances: - Cross Tower (North): True + Cross Tower (South): + static_painting: True Bearer Side Area: door: Side Area Shortcut Roof: True @@ -5162,7 +5171,10 @@ panel: SILENT (1) The Bearer (West): entrances: - Cross Tower (West): True + Cross Tower (West): + static_painting: True + The Bearer: + door: Side Area Shortcut Bearer Side Area: door: Side Area Shortcut Roof: True @@ -5235,6 +5247,7 @@ The Bearer: room: The Bearer door: East Entrance + static_painting: True Roof: True panels: WINTER: @@ -5250,6 +5263,7 @@ The Bearer (East): room: The Bearer (East) door: North Entrance + static_painting: True Roof: True panels: NORTH: @@ -5270,6 +5284,7 @@ The Bearer (North): room: The Bearer (North) door: South Entrance + static_painting: True panels: FIRE: id: Cross Room/Panel_fire_fire @@ -5284,6 +5299,7 @@ Bearer Side Area: room: Bearer Side Area door: West Entrance + static_painting: True Roof: True panels: DIAMONDS: @@ -7108,6 +7124,8 @@ entrances: Orange Tower Third Floor: warp: True + Art Gallery (First Floor): + warp: True Art Gallery (Second Floor): warp: True Art Gallery (Third Floor): @@ -7125,22 +7143,6 @@ required_door: room: Number Hunt door: Eights - EON: - id: Painting Room/Panel_eon_one - colors: yellow - tag: midyellow - TRUSTWORTHY: - id: Painting Room/Panel_to_two - colors: red - tag: midred - FREE: - id: Painting Room/Panel_free_three - colors: purple - tag: midpurp - OUR: - id: Painting Room/Panel_our_four - colors: blue - tag: midblue ORDER: id: Painting Room/Panel_order_onepathmanyturns tag: forbid @@ -7159,15 +7161,8 @@ - scenery_painting_2c skip_location: True panels: - - EON - First Floor Puzzles: - skip_item: True - location_name: Art Gallery - First Floor Puzzles - panels: - - EON - - TRUSTWORTHY - - FREE - - OUR + - room: Art Gallery (First Floor) + panel: EON Third Floor: painting_id: - scenery_painting_3b @@ -7227,11 +7222,42 @@ - Third Floor - Fourth Floor - Fifth Floor + Art Gallery (First Floor): + entrances: + Art Gallery: + static_painting: True + panels: + EON: + id: Painting Room/Panel_eon_one + colors: yellow + tag: midyellow + TRUSTWORTHY: + id: Painting Room/Panel_to_two + colors: red + tag: midred + FREE: + id: Painting Room/Panel_free_three + colors: purple + tag: midpurp + OUR: + id: Painting Room/Panel_our_four + colors: blue + tag: midblue + doors: + Puzzles: + skip_item: True + location_name: Art Gallery - First Floor Puzzles + panels: + - EON + - TRUSTWORTHY + - FREE + - OUR Art Gallery (Second Floor): entrances: Art Gallery: room: Art Gallery door: Second Floor + static_painting: True panels: HOUSE: id: Painting Room/Panel_house_neighborhood @@ -7263,6 +7289,7 @@ Art Gallery: room: Art Gallery door: Third Floor + static_painting: True panels: AN: id: Painting Room/Panel_an_many @@ -7294,6 +7321,7 @@ Art Gallery: room: Art Gallery door: Fourth Floor + static_painting: True panels: URNS: id: Painting Room/Panel_urns_turns diff --git a/worlds/lingo/data/generated.dat b/worlds/lingo/data/generated.dat index 14f5570db1..f5eb3e0699 100644 Binary files a/worlds/lingo/data/generated.dat and b/worlds/lingo/data/generated.dat differ diff --git a/worlds/lingo/data/ids.yaml b/worlds/lingo/data/ids.yaml index 0a43592d3f..c30a21e6c8 100644 --- a/worlds/lingo/data/ids.yaml +++ b/worlds/lingo/data/ids.yaml @@ -727,11 +727,12 @@ panels: WANDER: 444975 Art Gallery: EIGHT: 444976 + ORDER: 444981 + Art Gallery (First Floor): EON: 444977 TRUSTWORTHY: 444978 FREE: 444979 OUR: 444980 - ORDER: 444981 Art Gallery (Second Floor): HOUSE: 444982 PATH: 444983 @@ -1382,8 +1383,6 @@ doors: Art Gallery: Second Floor: item: 444558 - First Floor Puzzles: - location: 445256 Third Floor: item: 444559 Fourth Floor: @@ -1393,6 +1392,9 @@ doors: Exit: item: 444562 location: 444981 + Art Gallery (First Floor): + Puzzles: + location: 445256 Art Gallery (Second Floor): Puzzles: location: 445257 diff --git a/worlds/lingo/datatypes.py b/worlds/lingo/datatypes.py index 9521422ab1..961f922202 100644 --- a/worlds/lingo/datatypes.py +++ b/worlds/lingo/datatypes.py @@ -23,6 +23,7 @@ class EntranceType(Flag): SUNWARP = auto() WARP = auto() CROSSROADS_ROOF_ACCESS = auto() + STATIC_PAINTING = auto() class RoomEntrance(NamedTuple): diff --git a/worlds/lingo/player_logic.py b/worlds/lingo/player_logic.py index 9363dfedb6..b76f12a916 100644 --- a/worlds/lingo/player_logic.py +++ b/worlds/lingo/player_logic.py @@ -394,7 +394,7 @@ class LingoPlayerLogic: or painting.room in required_painting_rooms: return False - if world.options.shuffle_doors == ShuffleDoors.option_none: + if world.options.shuffle_doors != ShuffleDoors.option_doors: if painting.req_blocked_when_no_doors: return False diff --git a/worlds/lingo/regions.py b/worlds/lingo/regions.py index 9773f22d91..e029513a33 100644 --- a/worlds/lingo/regions.py +++ b/worlds/lingo/regions.py @@ -30,7 +30,7 @@ def is_acceptable_pilgrimage_entrance(entrance_type: EntranceType, world: "Lingo allowed_entrance_types = EntranceType.NORMAL if world.options.pilgrimage_allows_paintings: - allowed_entrance_types |= EntranceType.PAINTING + allowed_entrance_types |= EntranceType.PAINTING | EntranceType.STATIC_PAINTING if world.options.pilgrimage_allows_roof_access: allowed_entrance_types |= EntranceType.CROSSROADS_ROOF_ACCESS @@ -105,7 +105,8 @@ def create_regions(world: "LingoWorld") -> None: regions[pilgrimage_region_name] = Region(pilgrimage_region_name, world.player, world.multiworld) # Connect all created regions now that they exist. - allowed_entrance_types = EntranceType.NORMAL | EntranceType.WARP | EntranceType.CROSSROADS_ROOF_ACCESS + allowed_entrance_types = EntranceType.NORMAL | EntranceType.WARP | EntranceType.CROSSROADS_ROOF_ACCESS | \ + EntranceType.STATIC_PAINTING if not painting_shuffle: # Don't use the vanilla painting connections if we are shuffling paintings. @@ -156,11 +157,11 @@ def create_regions(world: "LingoWorld") -> None: regions[from_room].connect(regions[to_room], f"Pilgrimage Part {i+1}") else: connect_entrance(regions, regions["Starting Room"], regions["Pilgrim Antechamber"], "Sun Painting", - RoomAndDoor("Pilgrim Antechamber", "Sun Painting"), EntranceType.PAINTING, False, world) + RoomAndDoor("Pilgrim Antechamber", "Sun Painting"), EntranceType.STATIC_PAINTING, False, world) if early_color_hallways: connect_entrance(regions, regions["Starting Room"], regions["Color Hallways"], "Early Color Hallways", - None, EntranceType.PAINTING, False, world) + None, EntranceType.STATIC_PAINTING, False, world) if painting_shuffle: for warp_enter, warp_exit in world.player_logic.painting_mapping.items(): diff --git a/worlds/lingo/utils/pickle_static_data.py b/worlds/lingo/utils/pickle_static_data.py index 740e129bcb..7f39b79836 100644 --- a/worlds/lingo/utils/pickle_static_data.py +++ b/worlds/lingo/utils/pickle_static_data.py @@ -138,6 +138,8 @@ def process_single_entrance(source_room: str, room_name: str, door_obj) -> RoomE entrance_type = EntranceType.WARP elif source_room == "Crossroads" and room_name == "Roof": entrance_type = EntranceType.CROSSROADS_ROOF_ACCESS + elif "static_painting" in door_obj and door_obj["static_painting"]: + entrance_type = EntranceType.STATIC_PAINTING if "painting" in door_obj and door_obj["painting"]: PAINTING_EXIT_ROOMS.add(room_name) diff --git a/worlds/lingo/utils/validate_config.rb b/worlds/lingo/utils/validate_config.rb index 2a765fbcad..e270422684 100644 --- a/worlds/lingo/utils/validate_config.rb +++ b/worlds/lingo/utils/validate_config.rb @@ -2,7 +2,7 @@ # the file are consistent. It also checks that the panel and door IDs mentioned # all exist in the map file. # -# Usage: validate_config.rb [config file] [map file] +# Usage: validate_config.rb [config file] [ids path] [map file] require 'set' require 'yaml' diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index e403ff59d8..88a0cec2ca 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -281,7 +281,7 @@ class MessengerWorld(World): disconnect_entrances(self) add_closed_portal_reqs(self) # i need portal shuffle to happen after rules exist so i can validate it - attempts = 5 + attempts = 20 if self.options.shuffle_portals: self.portal_mapping = [] self.spoiler_portal_mapping = {} diff --git a/worlds/minecraft/Constants.py b/worlds/minecraft/Constants.py deleted file mode 100644 index 1f7b6fa6ac..0000000000 --- a/worlds/minecraft/Constants.py +++ /dev/null @@ -1,26 +0,0 @@ -import os -import json -import pkgutil - -def load_data_file(*args) -> dict: - fname = "/".join(["data", *args]) - return json.loads(pkgutil.get_data(__name__, fname).decode()) - -# For historical reasons, these values are different. -# They remain different to ensure datapackage consistency. -# Do not separate other games' location and item IDs like this. -item_id_offset: int = 45000 -location_id_offset: int = 42000 - -item_info = load_data_file("items.json") -item_name_to_id = {name: item_id_offset + index \ - for index, name in enumerate(item_info["all_items"])} -item_name_to_id["Bee Trap"] = item_id_offset + 100 # historical reasons - -location_info = load_data_file("locations.json") -location_name_to_id = {name: location_id_offset + index \ - for index, name in enumerate(location_info["all_locations"])} - -exclusion_info = load_data_file("excluded_locations.json") - -region_info = load_data_file("regions.json") diff --git a/worlds/minecraft/ItemPool.py b/worlds/minecraft/ItemPool.py deleted file mode 100644 index 19bb70ed64..0000000000 --- a/worlds/minecraft/ItemPool.py +++ /dev/null @@ -1,55 +0,0 @@ -from math import ceil -from typing import List - -from BaseClasses import Item - -from . import Constants -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from . import MinecraftWorld - - -def get_junk_item_names(rand, k: int) -> str: - junk_weights = Constants.item_info["junk_weights"] - junk = rand.choices( - list(junk_weights.keys()), - weights=list(junk_weights.values()), - k=k) - return junk - -def build_item_pool(world: "MinecraftWorld") -> List[Item]: - multiworld = world.multiworld - player = world.player - - itempool = [] - total_location_count = len(multiworld.get_unfilled_locations(player)) - - required_pool = Constants.item_info["required_pool"] - - # Add required progression items - for item_name, num in required_pool.items(): - itempool += [world.create_item(item_name) for _ in range(num)] - - # Add structure compasses - if world.options.structure_compasses: - compasses = [name for name in world.item_name_to_id if "Structure Compass" in name] - for item_name in compasses: - itempool.append(world.create_item(item_name)) - - # Dragon egg shards - if world.options.egg_shards_required > 0: - num = world.options.egg_shards_available - itempool += [world.create_item("Dragon Egg Shard") for _ in range(num)] - - # Bee traps - bee_trap_percentage = world.options.bee_traps * 0.01 - if bee_trap_percentage > 0: - bee_trap_qty = ceil(bee_trap_percentage * (total_location_count - len(itempool))) - itempool += [world.create_item("Bee Trap") for _ in range(bee_trap_qty)] - - # Fill remaining itempool with randomly generated junk - junk = get_junk_item_names(world.random, total_location_count - len(itempool)) - itempool += [world.create_item(name) for name in junk] - - return itempool diff --git a/worlds/minecraft/Options.py b/worlds/minecraft/Options.py deleted file mode 100644 index 7d1377233e..0000000000 --- a/worlds/minecraft/Options.py +++ /dev/null @@ -1,143 +0,0 @@ -from Options import Choice, Toggle, DefaultOnToggle, Range, OptionList, DeathLink, PlandoConnections, \ - PerGameCommonOptions -from .Constants import region_info -from dataclasses import dataclass - - -class AdvancementGoal(Range): - """Number of advancements required to spawn bosses.""" - display_name = "Advancement Goal" - range_start = 0 - range_end = 114 - default = 40 - - -class EggShardsRequired(Range): - """Number of dragon egg shards to collect to spawn bosses.""" - display_name = "Egg Shards Required" - range_start = 0 - range_end = 50 - default = 0 - - -class EggShardsAvailable(Range): - """Number of dragon egg shards available to collect.""" - display_name = "Egg Shards Available" - range_start = 0 - range_end = 50 - default = 0 - - -class BossGoal(Choice): - """Bosses which must be defeated to finish the game.""" - display_name = "Required Bosses" - option_none = 0 - option_ender_dragon = 1 - option_wither = 2 - option_both = 3 - default = 1 - - @property - def dragon(self): - return self.value % 2 == 1 - - @property - def wither(self): - return self.value > 1 - - -class ShuffleStructures(DefaultOnToggle): - """Enables shuffling of villages, outposts, fortresses, bastions, and end cities.""" - display_name = "Shuffle Structures" - - -class StructureCompasses(DefaultOnToggle): - """Adds structure compasses to the item pool, which point to the nearest indicated structure.""" - display_name = "Structure Compasses" - - -class BeeTraps(Range): - """Replaces a percentage of junk items with bee traps, which spawn multiple angered bees around every player when - received.""" - display_name = "Bee Trap Percentage" - range_start = 0 - range_end = 100 - default = 0 - - -class CombatDifficulty(Choice): - """Modifies the level of items logically required for exploring dangerous areas and fighting bosses.""" - display_name = "Combat Difficulty" - option_easy = 0 - option_normal = 1 - option_hard = 2 - default = 1 - - -class HardAdvancements(Toggle): - """Enables certain RNG-reliant or tedious advancements.""" - display_name = "Include Hard Advancements" - - -class UnreasonableAdvancements(Toggle): - """Enables the extremely difficult advancements "How Did We Get Here?" and "Adventuring Time.\"""" - display_name = "Include Unreasonable Advancements" - - -class PostgameAdvancements(Toggle): - """Enables advancements that require spawning and defeating the required bosses.""" - display_name = "Include Postgame Advancements" - - -class SendDefeatedMobs(Toggle): - """Send killed mobs to other Minecraft worlds which have this option enabled.""" - display_name = "Send Defeated Mobs" - - -class StartingItems(OptionList): - """Start with these items. Each entry should be of this format: {item: "item_name", amount: #} - `item` can include components, and should be in an identical format to a `/give` command with - `"` escaped for json reasons. - - `amount` is optional and will default to 1 if omitted. - - example: - ``` - starting_items: [ - { "item": "minecraft:stick[minecraft:custom_name=\"{'text':'pointy stick'}\"]" }, - { "item": "minecraft:arrow[minecraft:rarity=epic]", amount: 64 } - ] - ``` - """ - display_name = "Starting Items" - - -class MCPlandoConnections(PlandoConnections): - entrances = set(connection[0] for connection in region_info["default_connections"]) - exits = set(connection[1] for connection in region_info["default_connections"]) - - @classmethod - def can_connect(cls, entrance, exit): - if exit in region_info["illegal_connections"] and entrance in region_info["illegal_connections"][exit]: - return False - return True - - -@dataclass -class MinecraftOptions(PerGameCommonOptions): - plando_connections: MCPlandoConnections - advancement_goal: AdvancementGoal - egg_shards_required: EggShardsRequired - egg_shards_available: EggShardsAvailable - required_bosses: BossGoal - shuffle_structures: ShuffleStructures - structure_compasses: StructureCompasses - - combat_difficulty: CombatDifficulty - include_hard_advancements: HardAdvancements - include_unreasonable_advancements: UnreasonableAdvancements - include_postgame_advancements: PostgameAdvancements - bee_traps: BeeTraps - send_defeated_mobs: SendDefeatedMobs - death_link: DeathLink - starting_items: StartingItems diff --git a/worlds/minecraft/Rules.py b/worlds/minecraft/Rules.py deleted file mode 100644 index 9a7be09a4a..0000000000 --- a/worlds/minecraft/Rules.py +++ /dev/null @@ -1,508 +0,0 @@ -from BaseClasses import CollectionState -from worlds.generic.Rules import exclusion_rules - -from . import Constants -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from . import MinecraftWorld - - -# Helper functions -# moved from logicmixin - -def has_iron_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) - - -def has_copper_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) - - -def has_gold_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return (state.has('Progressive Resource Crafting', player) - and ( - state.has('Progressive Tools', player, 2) - or state.can_reach_region('The Nether', player) - ) - ) - - -def has_diamond_pickaxe(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Progressive Tools', player, 3) and has_iron_ingots(world, state, player) - - -def craft_crossbow(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Archery', player) and has_iron_ingots(world, state, player) - - -def has_bottle(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Bottles', player) and state.has('Progressive Resource Crafting', player) - - -def has_spyglass(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return (has_copper_ingots(world, state, player) - and state.has('Spyglass', player) - and can_adventure(world, state, player) - ) - - -def can_enchant(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Enchanting', player) and has_diamond_pickaxe(world, state, player) # mine obsidian and lapis - - -def can_use_anvil(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return (state.has('Enchanting', player) - and state.has('Progressive Resource Crafting', player,2) - and has_iron_ingots(world, state, player) - ) - - -def fortress_loot(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: # saddles, blaze rods, wither skulls - return state.can_reach_region('Nether Fortress', player) and basic_combat(world, state, player) - - -def can_brew_potions(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Blaze Rods', player) and state.has('Brewing', player) and has_bottle(world, state, player) - - -def can_piglin_trade(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return (has_gold_ingots(world, state, player) - and ( - state.can_reach_region('The Nether', player) - or state.can_reach_region('Bastion Remnant', player) - )) - - -def overworld_villager(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - village_region = state.multiworld.get_region('Village', player).entrances[0].parent_region.name - if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village - return (state.can_reach_location('Zombie Doctor', player) - or ( - has_diamond_pickaxe(world, state, player) - and state.can_reach_region('Village', player) - )) - elif village_region == 'The End': - return state.can_reach_location('Zombie Doctor', player) - return state.can_reach_region('Village', player) - - -def enter_stronghold(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return state.has('Blaze Rods', player) and state.has('Brewing', player) and state.has('3 Ender Pearls', player) - - -# Difficulty-dependent functions -def combat_difficulty(world: "MinecraftWorld", state: CollectionState, player: int) -> str: - return world.options.combat_difficulty.current_key - - -def can_adventure(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - death_link_check = not world.options.death_link or state.has('Bed', player) - if combat_difficulty(world, state, player) == 'easy': - return state.has('Progressive Weapons', player, 2) and has_iron_ingots(world, state, player) and death_link_check - elif combat_difficulty(world, state, player) == 'hard': - return True - return (state.has('Progressive Weapons', player) and death_link_check and - (state.has('Progressive Resource Crafting', player) or state.has('Campfire', player))) - - -def basic_combat(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - if combat_difficulty(world, state, player) == 'easy': - return (state.has('Progressive Weapons', player, 2) - and state.has('Progressive Armor', player) - and state.has('Shield', player) - and has_iron_ingots(world, state, player) - ) - elif combat_difficulty(world, state, player) == 'hard': - return True - return (state.has('Progressive Weapons', player) - and ( - state.has('Progressive Armor', player) - or state.has('Shield', player) - ) - and has_iron_ingots(world, state, player) - ) - - -def complete_raid(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - reach_regions = (state.can_reach_region('Village', player) - and state.can_reach_region('Pillager Outpost', player)) - if combat_difficulty(world, state, player) == 'easy': - return (reach_regions - and state.has('Progressive Weapons', player, 3) - and state.has('Progressive Armor', player, 2) - and state.has('Shield', player) - and state.has('Archery', player) - and state.has('Progressive Tools', player, 2) - and has_iron_ingots(world, state, player) - ) - elif combat_difficulty(world, state, player) == 'hard': # might be too hard? - return (reach_regions - and state.has('Progressive Weapons', player, 2) - and has_iron_ingots(world, state, player) - and ( - state.has('Progressive Armor', player) - or state.has('Shield', player) - ) - ) - return (reach_regions - and state.has('Progressive Weapons', player, 2) - and has_iron_ingots(world, state, player) - and state.has('Progressive Armor', player) - and state.has('Shield', player) - ) - - -def can_kill_wither(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - normal_kill = (state.has("Progressive Weapons", player, 3) - and state.has("Progressive Armor", player, 2) - and can_brew_potions(world, state, player) - and can_enchant(world, state, player) - ) - if combat_difficulty(world, state, player) == 'easy': - return (fortress_loot(world, state, player) - and normal_kill - and state.has('Archery', player) - ) - elif combat_difficulty(world, state, player) == 'hard': # cheese kill using bedrock ceilings - return (fortress_loot(world, state, player) - and ( - normal_kill - or state.can_reach_region('The Nether', player) - or state.can_reach_region('The End', player) - ) - ) - - return fortress_loot(world, state, player) and normal_kill - - -def can_respawn_ender_dragon(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - return (state.can_reach_region('The Nether', player) - and state.can_reach_region('The End', player) - and state.has('Progressive Resource Crafting', player) # smelt sand into glass - ) - - -def can_kill_ender_dragon(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: - if combat_difficulty(world, state, player) == 'easy': - return (state.has("Progressive Weapons", player, 3) - and state.has("Progressive Armor", player, 2) - and state.has('Archery', player) - and can_brew_potions(world, state, player) - and can_enchant(world, state, player) - ) - if combat_difficulty(world, state, player) == 'hard': - return ( - ( - state.has('Progressive Weapons', player, 2) - and state.has('Progressive Armor', player) - ) or ( - state.has('Progressive Weapons', player, 1) - and state.has('Bed', player) # who needs armor when you can respawn right outside the chamber - ) - ) - return (state.has('Progressive Weapons', player, 2) - and state.has('Progressive Armor', player) - and state.has('Archery', player) - ) - - -def has_structure_compass(world: "MinecraftWorld", state: CollectionState, entrance_name: str, player: int) -> bool: - if not world.options.structure_compasses: - return True - return state.has(f"Structure Compass ({state.multiworld.get_entrance(entrance_name, player).connected_region.name})", player) - - -def get_rules_lookup(world, player: int): - rules_lookup = { - "entrances": { - "Nether Portal": lambda state: state.has('Flint and Steel', player) - and ( - state.has('Bucket', player) - or state.has('Progressive Tools', player, 3) - ) - and has_iron_ingots(world, state, player), - "End Portal": lambda state: enter_stronghold(world, state, player) - and state.has('3 Ender Pearls', player, 4), - "Overworld Structure 1": lambda state: can_adventure(world, state, player) - and has_structure_compass(world, state, "Overworld Structure 1", player), - "Overworld Structure 2": lambda state: can_adventure(world, state, player) - and has_structure_compass(world, state, "Overworld Structure 2", player), - "Nether Structure 1": lambda state: can_adventure(world, state, player) - and has_structure_compass(world, state, "Nether Structure 1", player), - "Nether Structure 2": lambda state: can_adventure(world, state, player) - and has_structure_compass(world, state, "Nether Structure 2", player), - "The End Structure": lambda state: can_adventure(world, state, player) - and has_structure_compass(world, state, "The End Structure", player), - }, - "locations": { - "Ender Dragon": lambda state: can_respawn_ender_dragon(world, state, player) - and can_kill_ender_dragon(world, state, player), - "Wither": lambda state: can_kill_wither(world, state, player), - "Blaze Rods": lambda state: fortress_loot(world, state, player), - "Who is Cutting Onions?": lambda state: can_piglin_trade(world, state, player), - "Oh Shiny": lambda state: can_piglin_trade(world, state, player), - "Suit Up": lambda state: state.has("Progressive Armor", player) - and has_iron_ingots(world, state, player), - "Very Very Frightening": lambda state: state.has("Channeling Book", player) - and can_use_anvil(world, state, player) - and can_enchant(world, state, player) - and overworld_villager(world, state, player), - "Hot Stuff": lambda state: state.has("Bucket", player) - and has_iron_ingots(world, state, player), - "Free the End": lambda state: can_respawn_ender_dragon(world, state, player) - and can_kill_ender_dragon(world, state, player), - "A Furious Cocktail": lambda state: (can_brew_potions(world, state, player) - and state.has("Fishing Rod", player) # Water Breathing - and state.can_reach_region("The Nether", player) # Regeneration, Fire Resistance, gold nuggets - and state.can_reach_region("Village", player) # Night Vision, Invisibility - and state.can_reach_location("Bring Home the Beacon", player)), - # Resistance - "Bring Home the Beacon": lambda state: can_kill_wither(world, state, player) - and has_diamond_pickaxe(world, state, player) - and state.has("Progressive Resource Crafting", player, 2), - "Not Today, Thank You": lambda state: state.has("Shield", player) - and has_iron_ingots(world, state, player), - "Isn't It Iron Pick": lambda state: state.has("Progressive Tools", player, 2) - and has_iron_ingots(world, state, player), - "Local Brewery": lambda state: can_brew_potions(world, state, player), - "The Next Generation": lambda state: can_respawn_ender_dragon(world, state, player) - and can_kill_ender_dragon(world, state, player), - "Fishy Business": lambda state: state.has("Fishing Rod", player), - "This Boat Has Legs": lambda state: ( - fortress_loot(world, state, player) - or complete_raid(world, state, player) - ) - and state.has("Saddle", player) - and state.has("Fishing Rod", player), - "Sniper Duel": lambda state: state.has("Archery", player), - "Great View From Up Here": lambda state: basic_combat(world, state, player), - "How Did We Get Here?": lambda state: (can_brew_potions(world, state, player) - and has_gold_ingots(world, state, player) # Absorption - and state.can_reach_region('End City', player) # Levitation - and state.can_reach_region('The Nether', player) # potion ingredients - and state.has("Fishing Rod", player) # Pufferfish, Nautilus Shells; spectral arrows - and state.has("Archery", player) - and state.can_reach_location("Bring Home the Beacon", player) # Haste - and state.can_reach_location("Hero of the Village", player)), # Bad Omen, Hero of the Village - "Bullseye": lambda state: state.has("Archery", player) - and state.has("Progressive Tools", player, 2) - and has_iron_ingots(world, state, player), - "Spooky Scary Skeleton": lambda state: basic_combat(world, state, player), - "Two by Two": lambda state: has_iron_ingots(world, state, player) - and state.has("Bucket", player) - and can_adventure(world, state, player), - "Two Birds, One Arrow": lambda state: craft_crossbow(world, state, player) - and can_enchant(world, state, player), - "Who's the Pillager Now?": lambda state: craft_crossbow(world, state, player), - "Getting an Upgrade": lambda state: state.has("Progressive Tools", player), - "Tactical Fishing": lambda state: state.has("Bucket", player) - and has_iron_ingots(world, state, player), - "Zombie Doctor": lambda state: can_brew_potions(world, state, player) - and has_gold_ingots(world, state, player), - "Ice Bucket Challenge": lambda state: has_diamond_pickaxe(world, state, player), - "Into Fire": lambda state: basic_combat(world, state, player), - "War Pigs": lambda state: basic_combat(world, state, player), - "Take Aim": lambda state: state.has("Archery", player), - "Total Beelocation": lambda state: state.has("Silk Touch Book", player) - and can_use_anvil(world, state, player) - and can_enchant(world, state, player), - "Arbalistic": lambda state: (craft_crossbow(world, state, player) - and state.has("Piercing IV Book", player) - and can_use_anvil(world, state, player) - and can_enchant(world, state, player) - ), - "The End... Again...": lambda state: can_respawn_ender_dragon(world, state, player) - and can_kill_ender_dragon(world, state, player), - "Acquire Hardware": lambda state: has_iron_ingots(world, state, player), - "Not Quite \"Nine\" Lives": lambda state: can_piglin_trade(world, state, player) - and state.has("Progressive Resource Crafting", player, 2), - "Cover Me With Diamonds": lambda state: state.has("Progressive Armor", player, 2) - and state.has("Progressive Tools", player, 2) - and has_iron_ingots(world, state, player), - "Sky's the Limit": lambda state: basic_combat(world, state, player), - "Hired Help": lambda state: state.has("Progressive Resource Crafting", player, 2) - and has_iron_ingots(world, state, player), - "Sweet Dreams": lambda state: state.has("Bed", player) - or state.can_reach_region('Village', player), - "You Need a Mint": lambda state: can_respawn_ender_dragon(world, state, player) - and has_bottle(world, state, player), - "Monsters Hunted": lambda state: (can_respawn_ender_dragon(world, state, player) - and can_kill_ender_dragon(world, state, player) - and can_kill_wither(world, state, player) - and state.has("Fishing Rod", player)), - "Enchanter": lambda state: can_enchant(world, state, player), - "Voluntary Exile": lambda state: basic_combat(world, state, player), - "Eye Spy": lambda state: enter_stronghold(world, state, player), - "Serious Dedication": lambda state: (can_brew_potions(world, state, player) - and state.has("Bed", player) - and has_diamond_pickaxe(world, state, player) - and has_gold_ingots(world, state, player)), - "Postmortal": lambda state: complete_raid(world, state, player), - "Adventuring Time": lambda state: can_adventure(world, state, player), - "Hero of the Village": lambda state: complete_raid(world, state, player), - "Hidden in the Depths": lambda state: can_brew_potions(world, state, player) - and state.has("Bed", player) - and has_diamond_pickaxe(world, state, player), - "Beaconator": lambda state: (can_kill_wither(world, state, player) - and has_diamond_pickaxe(world, state, player) - and state.has("Progressive Resource Crafting", player, 2)), - "Withering Heights": lambda state: can_kill_wither(world, state, player), - "A Balanced Diet": lambda state: (has_bottle(world, state, player) - and has_gold_ingots(world, state, player) - and state.has("Progressive Resource Crafting", player, 2) - and state.can_reach_region('The End', player)), - # notch apple, chorus fruit - "Subspace Bubble": lambda state: has_diamond_pickaxe(world, state, player), - "Country Lode, Take Me Home": lambda state: state.can_reach_location("Hidden in the Depths", player) - and has_gold_ingots(world, state, player), - "Bee Our Guest": lambda state: state.has("Campfire", player) - and has_bottle(world, state, player), - "Uneasy Alliance": lambda state: has_diamond_pickaxe(world, state, player) - and state.has('Fishing Rod', player), - "Diamonds!": lambda state: state.has("Progressive Tools", player, 2) - and has_iron_ingots(world, state, player), - "A Throwaway Joke": lambda state: can_adventure(world, state, player), - "Sticky Situation": lambda state: state.has("Campfire", player) - and has_bottle(world, state, player), - "Ol' Betsy": lambda state: craft_crossbow(world, state, player), - "Cover Me in Debris": lambda state: state.has("Progressive Armor", player, 2) - and state.has("8 Netherite Scrap", player, 2) - and state.has("Progressive Resource Crafting", player) - and has_diamond_pickaxe(world, state, player) - and has_iron_ingots(world, state, player) - and can_brew_potions(world, state, player) - and state.has("Bed", player), - "Hot Topic": lambda state: state.has("Progressive Resource Crafting", player), - "The Lie": lambda state: has_iron_ingots(world, state, player) - and state.has("Bucket", player), - "On a Rail": lambda state: has_iron_ingots(world, state, player) - and state.has('Progressive Tools', player, 2), - "When Pigs Fly": lambda state: ( - fortress_loot(world, state, player) - or complete_raid(world, state, player) - ) - and state.has("Saddle", player) - and state.has("Fishing Rod", player) - and can_adventure(world, state, player), - "Overkill": lambda state: can_brew_potions(world, state, player) - and ( - state.has("Progressive Weapons", player) - or state.can_reach_region('The Nether', player) - ), - "Librarian": lambda state: state.has("Enchanting", player), - "Overpowered": lambda state: has_iron_ingots(world, state, player) - and state.has('Progressive Tools', player, 2) - and basic_combat(world, state, player), - "Wax On": lambda state: has_copper_ingots(world, state, player) - and state.has('Campfire', player) - and state.has('Progressive Resource Crafting', player, 2), - "Wax Off": lambda state: has_copper_ingots(world, state, player) - and state.has('Campfire', player) - and state.has('Progressive Resource Crafting', player, 2), - "The Cutest Predator": lambda state: has_iron_ingots(world, state, player) - and state.has('Bucket', player), - "The Healing Power of Friendship": lambda state: has_iron_ingots(world, state, player) - and state.has('Bucket', player), - "Is It a Bird?": lambda state: has_spyglass(world, state, player) - and can_adventure(world, state, player), - "Is It a Balloon?": lambda state: has_spyglass(world, state, player), - "Is It a Plane?": lambda state: has_spyglass(world, state, player) - and can_respawn_ender_dragon(world, state, player), - "Surge Protector": lambda state: state.has("Channeling Book", player) - and can_use_anvil(world, state, player) - and can_enchant(world, state, player) - and overworld_villager(world, state, player), - "Light as a Rabbit": lambda state: can_adventure(world, state, player) - and has_iron_ingots(world, state, player) - and state.has('Bucket', player), - "Glow and Behold!": lambda state: can_adventure(world, state, player), - "Whatever Floats Your Goat!": lambda state: can_adventure(world, state, player), - "Caves & Cliffs": lambda state: has_iron_ingots(world, state, player) - and state.has('Bucket', player) - and state.has('Progressive Tools', player, 2), - "Feels like home": lambda state: has_iron_ingots(world, state, player) - and state.has('Bucket', player) - and state.has('Fishing Rod', player) - and ( - fortress_loot(world, state, player) - or complete_raid(world, state, player) - ) - and state.has("Saddle", player), - "Sound of Music": lambda state: state.has("Progressive Tools", player, 2) - and has_iron_ingots(world, state, player) - and basic_combat(world, state, player), - "Star Trader": lambda state: has_iron_ingots(world, state, player) - and state.has('Bucket', player) - and ( - state.can_reach_region("The Nether", player) # soul sand in nether - or state.can_reach_region("Nether Fortress", player) # soul sand in fortress if not in nether for water elevator - or can_piglin_trade(world, state, player) # piglins give soul sand - ) - and overworld_villager(world, state, player), - "Birthday Song": lambda state: state.can_reach_location("The Lie", player) - and state.has("Progressive Tools", player, 2) - and has_iron_ingots(world, state, player), - "Bukkit Bukkit": lambda state: state.has("Bucket", player) - and has_iron_ingots(world, state, player) - and can_adventure(world, state, player), - "It Spreads": lambda state: can_adventure(world, state, player) - and has_iron_ingots(world, state, player) - and state.has("Progressive Tools", player, 2), - "Sneak 100": lambda state: can_adventure(world, state, player) - and has_iron_ingots(world, state, player) - and state.has("Progressive Tools", player, 2), - "When the Squad Hops into Town": lambda state: can_adventure(world, state, player) - and state.has("Lead", player), - "With Our Powers Combined!": lambda state: can_adventure(world, state, player) - and state.has("Lead", player), - } - } - return rules_lookup - - -def set_rules(self: "MinecraftWorld") -> None: - multiworld = self.multiworld - player = self.player - - rules_lookup = get_rules_lookup(self, player) - - # Set entrance rules - for entrance_name, rule in rules_lookup["entrances"].items(): - multiworld.get_entrance(entrance_name, player).access_rule = rule - - # Set location rules - for location_name, rule in rules_lookup["locations"].items(): - multiworld.get_location(location_name, player).access_rule = rule - - # Set rules surrounding completion - bosses = self.options.required_bosses - postgame_advancements = set() - if bosses.dragon: - postgame_advancements.update(Constants.exclusion_info["ender_dragon"]) - if bosses.wither: - postgame_advancements.update(Constants.exclusion_info["wither"]) - - def location_count(state: CollectionState) -> int: - return len([location for location in multiworld.get_locations(player) if - location.address is not None and - location.can_reach(state)]) - - def defeated_bosses(state: CollectionState) -> bool: - return ((not bosses.dragon or state.has("Ender Dragon", player)) - and (not bosses.wither or state.has("Wither", player))) - - egg_shards = min(self.options.egg_shards_required.value, self.options.egg_shards_available.value) - completion_requirements = lambda state: (location_count(state) >= self.options.advancement_goal - and state.has("Dragon Egg Shard", player, egg_shards)) - multiworld.completion_condition[player] = lambda state: completion_requirements(state) and defeated_bosses(state) - - # Set exclusions on hard/unreasonable/postgame - excluded_advancements = set() - if not self.options.include_hard_advancements: - excluded_advancements.update(Constants.exclusion_info["hard"]) - if not self.options.include_unreasonable_advancements: - excluded_advancements.update(Constants.exclusion_info["unreasonable"]) - if not self.options.include_postgame_advancements: - excluded_advancements.update(postgame_advancements) - exclusion_rules(multiworld, player, excluded_advancements) diff --git a/worlds/minecraft/Structures.py b/worlds/minecraft/Structures.py deleted file mode 100644 index d4f62f3498..0000000000 --- a/worlds/minecraft/Structures.py +++ /dev/null @@ -1,59 +0,0 @@ -from . import Constants -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from . import MinecraftWorld - - -def shuffle_structures(self: "MinecraftWorld") -> None: - multiworld = self.multiworld - player = self.player - - default_connections = Constants.region_info["default_connections"] - illegal_connections = Constants.region_info["illegal_connections"] - - # Get all unpaired exits and all regions without entrances (except the Menu) - # This function is destructive on these lists. - exits = [exit.name for r in multiworld.regions if r.player == player for exit in r.exits if exit.connected_region is None] - structs = [r.name for r in multiworld.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] - exits_spoiler = exits[:] # copy the original order for the spoiler log - - pairs = {} - - def set_pair(exit, struct): - if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])): - pairs[exit] = struct - exits.remove(exit) - structs.remove(struct) - else: - raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({multiworld.player_name[player]})") - - # Connect plando structures first - if self.options.plando_connections: - for conn in self.options.plando_connections: - set_pair(conn.entrance, conn.exit) - - # The algorithm tries to place the most restrictive structures first. This algorithm always works on the - # relatively small set of restrictions here, but does not work on all possible inputs with valid configurations. - if self.options.shuffle_structures: - structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, []))) - for struct in structs[:]: - try: - exit = self.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) - except IndexError: - raise Exception(f"No valid structure placements remaining for player {player} ({self.player_name})") - set_pair(exit, struct) - else: # write remaining default connections - for (exit, struct) in default_connections: - if exit in exits: - set_pair(exit, struct) - - # Make sure we actually paired everything; might fail if plando - try: - assert len(exits) == len(structs) == 0 - except AssertionError: - raise Exception(f"Failed to connect all Minecraft structures for player {player} ({self.player_name})") - - for exit in exits_spoiler: - multiworld.get_entrance(exit, player).connect(multiworld.get_region(pairs[exit], player)) - if self.options.shuffle_structures or self.options.plando_connections: - multiworld.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) diff --git a/worlds/minecraft/__init__.py b/worlds/minecraft/__init__.py deleted file mode 100644 index 7ec9b4b2b8..0000000000 --- a/worlds/minecraft/__init__.py +++ /dev/null @@ -1,203 +0,0 @@ -import os -import json -import settings -import typing -from base64 import b64encode, b64decode -from typing import Dict, Any - -from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification, Location -from worlds.AutoWorld import World, WebWorld - -from . import Constants -from .Options import MinecraftOptions -from .Structures import shuffle_structures -from .ItemPool import build_item_pool, get_junk_item_names -from .Rules import set_rules - -client_version = 9 - - -class MinecraftSettings(settings.Group): - class ForgeDirectory(settings.OptionalUserFolderPath): - pass - - class ReleaseChannel(str): - """ - release channel, currently "release", or "beta" - any games played on the "beta" channel have a high likelihood of no longer working on the "release" channel. - """ - - class JavaExecutable(settings.OptionalUserFilePath): - """ - Path to Java executable. If not set, will attempt to fall back to Java system installation. - """ - - forge_directory: ForgeDirectory = ForgeDirectory("Minecraft NeoForge server") - max_heap_size: str = "2G" - release_channel: ReleaseChannel = ReleaseChannel("release") - java: JavaExecutable = JavaExecutable("") - - -class MinecraftWebWorld(WebWorld): - theme = "jungle" - bug_report_page = "https://github.com/KonoTyran/Minecraft_AP_Randomizer/issues/new?assignees=&labels=bug&template=bug_report.yaml&title=%5BBug%5D%3A+Brief+Description+of+bug+here" - - setup = Tutorial( - "Multiworld Setup Guide", - "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers" - "single-player, multiworld, and related software.", - "English", - "minecraft_en.md", - "minecraft/en", - ["Kono Tyran"] - ) - - setup_es = Tutorial( - setup.tutorial_name, - setup.description, - "Español", - "minecraft_es.md", - "minecraft/es", - ["Edos"] - ) - - setup_sv = Tutorial( - setup.tutorial_name, - setup.description, - "Swedish", - "minecraft_sv.md", - "minecraft/sv", - ["Albinum"] - ) - - setup_fr = Tutorial( - setup.tutorial_name, - setup.description, - "Français", - "minecraft_fr.md", - "minecraft/fr", - ["TheLynk"] - ) - - tutorials = [setup, setup_es, setup_sv, setup_fr] - - -class MinecraftWorld(World): - """ - Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine, - craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient - structures, and materials to create a portal to another world. Defeat the Ender Dragon, and claim - victory! - """ - game = "Minecraft" - options_dataclass = MinecraftOptions - options: MinecraftOptions - settings: typing.ClassVar[MinecraftSettings] - topology_present = True - web = MinecraftWebWorld() - - item_name_to_id = Constants.item_name_to_id - location_name_to_id = Constants.location_name_to_id - - def _get_mc_data(self) -> Dict[str, Any]: - exits = [connection[0] for connection in Constants.region_info["default_connections"]] - return { - 'world_seed': self.random.getrandbits(32), - 'seed_name': self.multiworld.seed_name, - 'player_name': self.player_name, - 'player_id': self.player, - 'client_version': client_version, - 'structures': {exit: self.multiworld.get_entrance(exit, self.player).connected_region.name for exit in exits}, - 'advancement_goal': self.options.advancement_goal.value, - 'egg_shards_required': min(self.options.egg_shards_required.value, - self.options.egg_shards_available.value), - 'egg_shards_available': self.options.egg_shards_available.value, - 'required_bosses': self.options.required_bosses.current_key, - 'MC35': bool(self.options.send_defeated_mobs.value), - 'death_link': bool(self.options.death_link.value), - 'starting_items': json.dumps(self.options.starting_items.value), - 'race': self.multiworld.is_race, - } - - def create_item(self, name: str) -> Item: - item_class = ItemClassification.filler - if name in Constants.item_info["progression_items"]: - item_class = ItemClassification.progression - elif name in Constants.item_info["useful_items"]: - item_class = ItemClassification.useful - elif name in Constants.item_info["trap_items"]: - item_class = ItemClassification.trap - - return MinecraftItem(name, item_class, self.item_name_to_id.get(name, None), self.player) - - def create_event(self, region_name: str, event_name: str) -> None: - region = self.multiworld.get_region(region_name, self.player) - loc = MinecraftLocation(self.player, event_name, None, region) - loc.place_locked_item(self.create_event_item(event_name)) - region.locations.append(loc) - - def create_event_item(self, name: str) -> Item: - item = self.create_item(name) - item.classification = ItemClassification.progression - return item - - def create_regions(self) -> None: - # Create regions - for region_name, exits in Constants.region_info["regions"]: - r = Region(region_name, self.player, self.multiworld) - for exit_name in exits: - r.exits.append(Entrance(self.player, exit_name, r)) - self.multiworld.regions.append(r) - - # Bind mandatory connections - for entr_name, region_name in Constants.region_info["mandatory_connections"]: - e = self.multiworld.get_entrance(entr_name, self.player) - r = self.multiworld.get_region(region_name, self.player) - e.connect(r) - - # Add locations - for region_name, locations in Constants.location_info["locations_by_region"].items(): - region = self.multiworld.get_region(region_name, self.player) - for loc_name in locations: - loc = MinecraftLocation(self.player, loc_name, - self.location_name_to_id.get(loc_name, None), region) - region.locations.append(loc) - - # Add events - self.create_event("Nether Fortress", "Blaze Rods") - self.create_event("The End", "Ender Dragon") - self.create_event("Nether Fortress", "Wither") - - # Shuffle the connections - shuffle_structures(self) - - def create_items(self) -> None: - self.multiworld.itempool += build_item_pool(self) - - set_rules = set_rules - - def generate_output(self, output_directory: str) -> None: - data = self._get_mc_data() - filename = f"{self.multiworld.get_out_file_name_base(self.player)}.apmc" - with open(os.path.join(output_directory, filename), 'wb') as f: - f.write(b64encode(bytes(json.dumps(data), 'utf-8'))) - - def fill_slot_data(self) -> dict: - return self._get_mc_data() - - def get_filler_item_name(self) -> str: - return get_junk_item_names(self.random, 1)[0] - - -class MinecraftLocation(Location): - game = "Minecraft" - -class MinecraftItem(Item): - game = "Minecraft" - - -def mc_update_output(raw_data, server, port): - data = json.loads(b64decode(raw_data)) - data['server'] = server - data['port'] = port - return b64encode(bytes(json.dumps(data), 'utf-8')) diff --git a/worlds/minecraft/data/excluded_locations.json b/worlds/minecraft/data/excluded_locations.json deleted file mode 100644 index 2f6fbbba6d..0000000000 --- a/worlds/minecraft/data/excluded_locations.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "hard": [ - "Very Very Frightening", - "A Furious Cocktail", - "Two by Two", - "Two Birds, One Arrow", - "Arbalistic", - "Monsters Hunted", - "Beaconator", - "A Balanced Diet", - "Uneasy Alliance", - "Cover Me in Debris", - "A Complete Catalogue", - "Surge Protector", - "Sound of Music", - "Star Trader", - "When the Squad Hops into Town", - "With Our Powers Combined!" - ], - "unreasonable": [ - "How Did We Get Here?", - "Adventuring Time" - ], - "ender_dragon": [ - "Free the End", - "The Next Generation", - "The End... Again...", - "You Need a Mint", - "Monsters Hunted", - "Is It a Plane?" - ], - "wither": [ - "Withering Heights", - "Bring Home the Beacon", - "Beaconator", - "A Furious Cocktail", - "How Did We Get Here?", - "Monsters Hunted" - ] -} \ No newline at end of file diff --git a/worlds/minecraft/data/items.json b/worlds/minecraft/data/items.json deleted file mode 100644 index 7d35d18aeb..0000000000 --- a/worlds/minecraft/data/items.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "all_items": [ - "Archery", - "Progressive Resource Crafting", - "Resource Blocks", - "Brewing", - "Enchanting", - "Bucket", - "Flint and Steel", - "Bed", - "Bottles", - "Shield", - "Fishing Rod", - "Campfire", - "Progressive Weapons", - "Progressive Tools", - "Progressive Armor", - "8 Netherite Scrap", - "8 Emeralds", - "4 Emeralds", - "Channeling Book", - "Silk Touch Book", - "Sharpness III Book", - "Piercing IV Book", - "Looting III Book", - "Infinity Book", - "4 Diamond Ore", - "16 Iron Ore", - "500 XP", - "100 XP", - "50 XP", - "3 Ender Pearls", - "4 Lapis Lazuli", - "16 Porkchops", - "8 Gold Ore", - "Rotten Flesh", - "Single Arrow", - "32 Arrows", - "Saddle", - "Structure Compass (Village)", - "Structure Compass (Pillager Outpost)", - "Structure Compass (Nether Fortress)", - "Structure Compass (Bastion Remnant)", - "Structure Compass (End City)", - "Shulker Box", - "Dragon Egg Shard", - "Spyglass", - "Lead", - "Bee Trap" - ], - "progression_items": [ - "Archery", - "Progressive Resource Crafting", - "Resource Blocks", - "Brewing", - "Enchanting", - "Bucket", - "Flint and Steel", - "Bed", - "Bottles", - "Shield", - "Fishing Rod", - "Campfire", - "Progressive Weapons", - "Progressive Tools", - "Progressive Armor", - "8 Netherite Scrap", - "Channeling Book", - "Silk Touch Book", - "Piercing IV Book", - "3 Ender Pearls", - "Saddle", - "Structure Compass (Village)", - "Structure Compass (Pillager Outpost)", - "Structure Compass (Nether Fortress)", - "Structure Compass (Bastion Remnant)", - "Structure Compass (End City)", - "Dragon Egg Shard", - "Spyglass", - "Lead" - ], - "useful_items": [ - "Sharpness III Book", - "Looting III Book", - "Infinity Book" - ], - "trap_items": [ - "Bee Trap" - ], - - "required_pool": { - "Archery": 1, - "Progressive Resource Crafting": 2, - "Brewing": 1, - "Enchanting": 1, - "Bucket": 1, - "Flint and Steel": 1, - "Bed": 1, - "Bottles": 1, - "Shield": 1, - "Fishing Rod": 1, - "Campfire": 1, - "Progressive Weapons": 3, - "Progressive Tools": 3, - "Progressive Armor": 2, - "8 Netherite Scrap": 2, - "Channeling Book": 1, - "Silk Touch Book": 1, - "Sharpness III Book": 1, - "Piercing IV Book": 1, - "Looting III Book": 1, - "Infinity Book": 1, - "3 Ender Pearls": 4, - "Saddle": 1, - "Spyglass": 1, - "Lead": 1 - }, - "junk_weights": { - "4 Emeralds": 2, - "4 Diamond Ore": 1, - "16 Iron Ore": 1, - "50 XP": 4, - "16 Porkchops": 2, - "8 Gold Ore": 1, - "Rotten Flesh": 1, - "32 Arrows": 1 - } -} \ No newline at end of file diff --git a/worlds/minecraft/data/locations.json b/worlds/minecraft/data/locations.json deleted file mode 100644 index 7cd00e5851..0000000000 --- a/worlds/minecraft/data/locations.json +++ /dev/null @@ -1,250 +0,0 @@ -{ - "all_locations": [ - "Who is Cutting Onions?", - "Oh Shiny", - "Suit Up", - "Very Very Frightening", - "Hot Stuff", - "Free the End", - "A Furious Cocktail", - "Best Friends Forever", - "Bring Home the Beacon", - "Not Today, Thank You", - "Isn't It Iron Pick", - "Local Brewery", - "The Next Generation", - "Fishy Business", - "Hot Tourist Destinations", - "This Boat Has Legs", - "Sniper Duel", - "Nether", - "Great View From Up Here", - "How Did We Get Here?", - "Bullseye", - "Spooky Scary Skeleton", - "Two by Two", - "Stone Age", - "Two Birds, One Arrow", - "We Need to Go Deeper", - "Who's the Pillager Now?", - "Getting an Upgrade", - "Tactical Fishing", - "Zombie Doctor", - "The City at the End of the Game", - "Ice Bucket Challenge", - "Remote Getaway", - "Into Fire", - "War Pigs", - "Take Aim", - "Total Beelocation", - "Arbalistic", - "The End... Again...", - "Acquire Hardware", - "Not Quite \"Nine\" Lives", - "Cover Me With Diamonds", - "Sky's the Limit", - "Hired Help", - "Return to Sender", - "Sweet Dreams", - "You Need a Mint", - "Adventure", - "Monsters Hunted", - "Enchanter", - "Voluntary Exile", - "Eye Spy", - "The End", - "Serious Dedication", - "Postmortal", - "Monster Hunter", - "Adventuring Time", - "A Seedy Place", - "Those Were the Days", - "Hero of the Village", - "Hidden in the Depths", - "Beaconator", - "Withering Heights", - "A Balanced Diet", - "Subspace Bubble", - "Husbandry", - "Country Lode, Take Me Home", - "Bee Our Guest", - "What a Deal!", - "Uneasy Alliance", - "Diamonds!", - "A Terrible Fortress", - "A Throwaway Joke", - "Minecraft", - "Sticky Situation", - "Ol' Betsy", - "Cover Me in Debris", - "The End?", - "The Parrots and the Bats", - "A Complete Catalogue", - "Getting Wood", - "Time to Mine!", - "Hot Topic", - "Bake Bread", - "The Lie", - "On a Rail", - "Time to Strike!", - "Cow Tipper", - "When Pigs Fly", - "Overkill", - "Librarian", - "Overpowered", - "Wax On", - "Wax Off", - "The Cutest Predator", - "The Healing Power of Friendship", - "Is It a Bird?", - "Is It a Balloon?", - "Is It a Plane?", - "Surge Protector", - "Light as a Rabbit", - "Glow and Behold!", - "Whatever Floats Your Goat!", - "Caves & Cliffs", - "Feels like home", - "Sound of Music", - "Star Trader", - "Birthday Song", - "Bukkit Bukkit", - "It Spreads", - "Sneak 100", - "When the Squad Hops into Town", - "With Our Powers Combined!", - "You've Got a Friend in Me" - ], - "locations_by_region": { - "Overworld": [ - "Who is Cutting Onions?", - "Oh Shiny", - "Suit Up", - "Very Very Frightening", - "Hot Stuff", - "Best Friends Forever", - "Not Today, Thank You", - "Isn't It Iron Pick", - "Fishy Business", - "Sniper Duel", - "Bullseye", - "Stone Age", - "Two Birds, One Arrow", - "Getting an Upgrade", - "Tactical Fishing", - "Zombie Doctor", - "Ice Bucket Challenge", - "Take Aim", - "Total Beelocation", - "Arbalistic", - "Acquire Hardware", - "Cover Me With Diamonds", - "Hired Help", - "Sweet Dreams", - "Adventure", - "Monsters Hunted", - "Enchanter", - "Eye Spy", - "Monster Hunter", - "Adventuring Time", - "A Seedy Place", - "Husbandry", - "Bee Our Guest", - "Diamonds!", - "A Throwaway Joke", - "Minecraft", - "Sticky Situation", - "Ol' Betsy", - "The Parrots and the Bats", - "Getting Wood", - "Time to Mine!", - "Hot Topic", - "Bake Bread", - "The Lie", - "On a Rail", - "Time to Strike!", - "Cow Tipper", - "When Pigs Fly", - "Librarian", - "Wax On", - "Wax Off", - "The Cutest Predator", - "The Healing Power of Friendship", - "Is It a Bird?", - "Surge Protector", - "Light as a Rabbit", - "Glow and Behold!", - "Whatever Floats Your Goat!", - "Caves & Cliffs", - "Sound of Music", - "Bukkit Bukkit", - "It Spreads", - "Sneak 100", - "When the Squad Hops into Town" - ], - "The Nether": [ - "Hot Tourist Destinations", - "This Boat Has Legs", - "Nether", - "Two by Two", - "We Need to Go Deeper", - "Not Quite \"Nine\" Lives", - "Return to Sender", - "Serious Dedication", - "Hidden in the Depths", - "Subspace Bubble", - "Country Lode, Take Me Home", - "Uneasy Alliance", - "Cover Me in Debris", - "Is It a Balloon?", - "Feels like home", - "With Our Powers Combined!" - ], - "The End": [ - "Free the End", - "The Next Generation", - "Remote Getaway", - "The End... Again...", - "You Need a Mint", - "The End", - "The End?", - "Is It a Plane?" - ], - "Village": [ - "Postmortal", - "Hero of the Village", - "A Balanced Diet", - "What a Deal!", - "A Complete Catalogue", - "Star Trader" - ], - "Nether Fortress": [ - "A Furious Cocktail", - "Bring Home the Beacon", - "Local Brewery", - "How Did We Get Here?", - "Spooky Scary Skeleton", - "Into Fire", - "Beaconator", - "Withering Heights", - "A Terrible Fortress", - "Overkill" - ], - "Pillager Outpost": [ - "Who's the Pillager Now?", - "Voluntary Exile", - "Birthday Song", - "You've Got a Friend in Me" - ], - "Bastion Remnant": [ - "War Pigs", - "Those Were the Days", - "Overpowered" - ], - "End City": [ - "Great View From Up Here", - "The City at the End of the Game", - "Sky's the Limit" - ] - } -} \ No newline at end of file diff --git a/worlds/minecraft/data/regions.json b/worlds/minecraft/data/regions.json deleted file mode 100644 index c9e51e4829..0000000000 --- a/worlds/minecraft/data/regions.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "regions": [ - ["Menu", ["New World"]], - ["Overworld", ["Nether Portal", "End Portal", "Overworld Structure 1", "Overworld Structure 2"]], - ["The Nether", ["Nether Structure 1", "Nether Structure 2"]], - ["The End", ["The End Structure"]], - ["Village", []], - ["Pillager Outpost", []], - ["Nether Fortress", []], - ["Bastion Remnant", []], - ["End City", []] - ], - "mandatory_connections": [ - ["New World", "Overworld"], - ["Nether Portal", "The Nether"], - ["End Portal", "The End"] - ], - "default_connections": [ - ["Overworld Structure 1", "Village"], - ["Overworld Structure 2", "Pillager Outpost"], - ["Nether Structure 1", "Nether Fortress"], - ["Nether Structure 2", "Bastion Remnant"], - ["The End Structure", "End City"] - ], - "illegal_connections": { - "Nether Fortress": ["The End Structure"] - } -} \ No newline at end of file diff --git a/worlds/minecraft/docs/en_Minecraft.md b/worlds/minecraft/docs/en_Minecraft.md deleted file mode 100644 index 3a69a7f59a..0000000000 --- a/worlds/minecraft/docs/en_Minecraft.md +++ /dev/null @@ -1,113 +0,0 @@ -# Minecraft - -## Where is the options page? - -The [player options page for this game](../player-options) contains all the options you need to configure and export a -config file. - -## What does randomization do to this game? - -Some recipes are locked from being able to be crafted and shuffled into the item pool. It can also optionally change which -structures appear in each dimension. Crafting recipes are re-learned when they are received from other players as item -checks, and occasionally when completing your own achievements. See below for which recipes are shuffled. - -## What is considered a location check in Minecraft? - -Location checks are completed when the player completes various Minecraft achievements. Opening the advancements menu -in-game by pressing "L" will display outstanding achievements. - -## When the player receives an item, what happens? - -When the player receives an item in Minecraft, it either unlocks crafting recipes or puts items into the player's -inventory directly. - -## What is the victory condition? - -Victory is achieved when the player kills the Ender Dragon, enters the portal in The End, and completes the credits -sequence either by skipping it or watching it play out. - -## Which recipes are locked? - -* Archery - * Bow - * Arrow - * Crossbow -* Brewing - * Blaze Powder - * Brewing Stand -* Enchanting - * Enchanting Table - * Bookshelf -* Bucket -* Flint & Steel -* All Beds -* Bottles -* Shield -* Fishing Rod - * Fishing Rod - * Carrot on a Stick - * Warped Fungus on a Stick -* Campfire - * Campfire - * Soul Campfire -* Spyglass -* Lead -* Progressive Weapons - * Tier I - * Stone Sword - * Stone Axe - * Tier II - * Iron Sword - * Iron Axe - * Tier III - * Diamond Sword - * Diamond Axe -* Progessive Tools - * Tier I - * Stone Pickaxe - * Stone Shovel - * Stone Hoe - * Tier II - * Iron Pickaxe - * Iron Shovel - * Iron Hoe - * Tier III - * Diamond Pickaxe - * Diamond Shovel - * Diamond Hoe - * Netherite Ingot -* Progressive Armor - * Tier I - * Iron Helmet - * Iron Chestplate - * Iron Leggings - * Iron Boots - * Tier II - * Diamond Helmet - * Diamond Chestplate - * Diamond Leggings - * Diamond Boots -* Progressive Resource Crafting - * Tier I - * Iron Ingot from Nuggets - * Iron Nugget - * Gold Ingot from Nuggets - * Gold Nugget - * Furnace - * Blast Furnace - * Tier II - * Redstone - * Redstone Block - * Glowstone - * Iron Ingot from Iron Block - * Iron Block - * Gold Ingot from Gold Block - * Gold Block - * Diamond - * Diamond Block - * Netherite Block - * Netherite Ingot from Netherite Block - * Anvil - * Emerald - * Emerald Block - * Copper Block diff --git a/worlds/minecraft/docs/minecraft_en.md b/worlds/minecraft/docs/minecraft_en.md deleted file mode 100644 index e0b5ae3b98..0000000000 --- a/worlds/minecraft/docs/minecraft_en.md +++ /dev/null @@ -1,74 +0,0 @@ -# Minecraft Randomizer Setup Guide - -## Required Software - -- Minecraft Java Edition from - the [Minecraft Java Edition Store Page](https://www.minecraft.net/en-us/store/minecraft-java-edition) -- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) - -## Configuring your YAML file - -### What is a YAML 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 YAML file? - -You can customize your options by visiting the [Minecraft Player Options Page](/games/Minecraft/player-options) - -## Joining a MultiWorld Game - -### Obtain Your Minecraft Data File - -**Only one yaml file needs to be submitted per minecraft world regardless of how many players play on it.** - -When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done, -the host will provide you with either a link to download your data file, or with a zip file containing everyone's data -files. Your data file should have a `.apmc` extension. - -Double-click on your `.apmc` file to have the Minecraft client auto-launch the installed forge server. Make sure to -leave this window open as this is your server console. - -### Connect to the MultiServer - -Open Minecraft, go to `Multiplayer > Direct Connection`, and join the `localhost` server address. - -If you are using the website to host the game then it should auto-connect to the AP server without the need to `/connect` - -otherwise once you are in game type `/connect (Port) (Password)` where `` is the address of the -Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. Note that there is no colon between `` and `(Port)`. -`(Password)` is only required if the Archipelago server you are using has a password set. - -### Play the game - -When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a -multiworld game! At this point any additional minecraft players may connect to your forge server. To start the game once -everyone is ready use the command `/start`. - -## Non-Windows Installation - -The Minecraft Client will install forge and the mod for other operating systems but Java has to be provided by the -user. Head to [minecraft_versions.json on the MC AP GitHub](https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json) -to see which java version is required. New installations will default to the topmost "release" version. -- Install the matching Amazon Corretto JDK - - see [Manual Installation Software Links](#manual-installation-software-links) - - or package manager provided by your OS / distribution -- Open your `host.yaml` and add the path to your Java below the `minecraft_options` key - - ` java: "path/to/java-xx-amazon-corretto/bin/java"` -- Run the Minecraft Client and select your .apmc file - -## Full Manual Installation - -It is highly recommended to ues the Archipelago installer to handle the installation of the forge server for you. -Support will not be given for those wishing to manually install forge. For those of you who know how, and wish to do so, -the following links are the versions of the software we use. - -### Manual Installation Software Links - -- [Minecraft Forge Download Page](https://files.minecraftforge.net/net/minecraftforge/forge/) -- [Minecraft Archipelago Randomizer Mod Releases Page](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases) - - **DO NOT INSTALL THIS ON YOUR CLIENT** -- [Amazon Corretto](https://docs.aws.amazon.com/corretto/) - - pick the matching version and select "Downloads" on the left - diff --git a/worlds/minecraft/docs/minecraft_es.md b/worlds/minecraft/docs/minecraft_es.md deleted file mode 100644 index 4f48992122..0000000000 --- a/worlds/minecraft/docs/minecraft_es.md +++ /dev/null @@ -1,148 +0,0 @@ -# Guia instalación de Minecraft Randomizer - -# Instalacion automatica para el huesped de partida - -- descarga e instala [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and activa el - modulo `Minecraft Client` - -## Software Requerido - -- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) - -## Configura tu fichero YAML - -### Que es un fichero YAML y potque necesito uno? - -Tu fichero YAML contiene un numero de opciones que proveen al generador con informacion sobre como debe generar tu -juego. Cada jugador de un multiworld entregara u propio fichero YAML. Esto permite que cada jugador disfrute de una -experiencia personalizada a su gusto y diferentes jugadores dentro del mismo multiworld pueden tener diferentes opciones - -### Where do I get a YAML file? - -Un fichero basico yaml para minecraft tendra este aspecto. - -```yaml -description: Basic Minecraft Yaml -# Tu nombre en el juego. Espacios seran sustituidos por guinoes bajos y -# hay un limite de 16 caracteres -name: TuNombre -game: Minecraft - -# Opciones compartidas por todos los juegos: -accessibility: full -progression_balancing: 50 -# Opciones Especficicas para Minecraft - -Minecraft: - # Numero de logros requeridos (87 max) para que aparezca el Ender Dragon y completar el juego. - advancement_goal: 50 - - # Numero de trozos de huevo de dragon a obtener (30 max) antes de que el Ender Dragon aparezca. - egg_shards_required: 10 - - # Numero de huevos disponibles en la partida (30 max). - egg_shards_available: 15 - - # Modifica el nivel de objetos logicamente requeridos para - # explorar areas peligrosas y luchar contra jefes. - combat_difficulty: - easy: 0 - normal: 1 - hard: 0 - - # Si off, los logros que dependan de suerte o sean tediosos tendran objetos de apoyo, no necesarios para completar el juego. - include_hard_advancements: - on: 0 - off: 1 - - # Si off, los logros muy dificiles tendran objetos de apoyo, no necesarios para completar el juego. - # Solo afecta a How Did We Get Here? and Adventuring Time. - include_insane_advancements: - on: 0 - off: 1 - - # Algunos logros requieren derrotar al Ender Dragon; - # Si esto se queda en off, dichos logros no tendran objetos necesarios. - include_postgame_advancements: - on: 0 - off: 1 - - # Permite el mezclado de villas, puesto, fortalezas, bastiones y ciudades de END. - shuffle_structures: - on: 0 - off: 1 - - # Añade brujulas de estructura al juego, - # apuntaran a la estructura correspondiente mas cercana. - structure_compasses: - on: 0 - off: 1 - - # Reemplaza un porcentaje de objetos innecesarios por trampas abeja - # las cuales crearan multiples abejas agresivas alrededor de los jugadores cuando se reciba. - bee_traps: - 0: 1 - 25: 0 - 50: 0 - 75: 0 - 100: 0 -``` - -## Unirse a un juego MultiWorld - -### Obten tu ficheros de datos Minecraft - -**Solo un fichero yaml es necesario por mundo minecraft, sin importar el numero de jugadores que jueguen en el.** - -Cuando te unes a un juego multiworld, se te pedirá que entregues tu fichero YAML a quien sea que hospede el juego -multiworld (no confundir con hospedar el mundo minecraft). Una vez la generación acabe, el anfitrión te dará un enlace a -tu fichero de datos o un zip con los ficheros de todos. Tu fichero de datos tiene una extensión `.apmc`. - -Haz doble click en tu fichero `.apmc` para que se arranque el cliente de minecraft y el servidor forge se ejecute. - -### Conectar al multiserver - -Despues de poner tu fichero en el directorio `APData`, arranca el Forge server y asegurate que tienes el estado OP -tecleando `/op TuUsuarioMinecraft` en la consola del servidor y entonces conectate con tu cliente Minecraft. - -Una vez en juego introduce `/connect (Port) ()` donde `` es la dirección del -servidor. `(Port)` solo es requerido si el servidor Archipelago no esta usando el puerto por defecto 38281. -`()` -solo se necesita si el servidor Archipleago tiene un password activo. - -### Jugar al juego - -Cuando la consola te diga que te has unido a la sala, estas lista/o para empezar a jugar. Felicidades por unirte -exitosamente a un juego multiworld! Llegados a este punto cualquier jugador adicional puede conectarse a tu servidor -forge. - -## Procedimiento de instalación manual - -Solo es requerido si quieres usar una instalacion de forge por ti mismo, recomendamos usar el instalador de Archipelago - -### Software Requerido - -- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html) -- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases) - **NO INSTALES ESTO EN TU CLIENTE MINECRAFT** - -### Instalación de servidor dedicado - -Solo una persona ha de realizar este proceso y hospedar un servidor dedicado para que los demas jueguen conectandose a -él. - -1. Descarga el instalador de **Minecraft Forge** 1.16.5 desde el enlace proporcionado, siempre asegurandose de bajar la - version mas reciente. - -2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elije **install server**. - - En esta pagina elegiras ademas donde instalar el servidor, importante recordar esta localización en el siguiente - paso. - -3. Navega al directorio donde hayas instalado el servidor y abre `forge-1.16.5-xx.x.x.jar` - - La primera vez que lances el servidor se cerrara (o no aparecerá nada en absoluto), debería haber un fichero nuevo - en el directorio llamado `eula.txt`, el cual que contiene un enlace al EULA de minecraft, cambia la linea - a `eula=true` para aceptar el EULA y poder utilizar el software de servidor. - - Esto creara la estructura de directorios apropiada para el siguiente paso - -4. Coloca el fichero `aprandomizer-x.x.x.jar` del segundo enlace en el directorio `mods` - - Cuando se ejecute el servidor de nuevo, generara el directorio `APData` que se necesitara para jugar diff --git a/worlds/minecraft/docs/minecraft_fr.md b/worlds/minecraft/docs/minecraft_fr.md deleted file mode 100644 index 31c48151f4..0000000000 --- a/worlds/minecraft/docs/minecraft_fr.md +++ /dev/null @@ -1,74 +0,0 @@ -# Guide de configuration du randomiseur Minecraft - -## Logiciel requis - -- Minecraft Java Edition à partir de - la [page de la boutique Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) -- Archipelago depuis la [page des versions d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - - (sélectionnez `Minecraft Client` lors de l'installation.) - -## Configuration de votre fichier YAML - -### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ? - -Voir le guide sur la configuration d'un YAML de base lors de la configuration d'Archipelago -guide : [Guide de configuration de base de Multiworld](/tutorial/Archipelago/setup/en) - -### Où puis-je obtenir un fichier YAML ? - -Vous pouvez personnaliser vos paramètres Minecraft en allant sur la [page des paramètres de joueur](/games/Minecraft/player-options) - -## Rejoindre une partie MultiWorld - -### Obtenez votre fichier de données Minecraft - -**Un seul fichier yaml doit être soumis par monde minecraft, quel que soit le nombre de joueurs qui y jouent.** - -Lorsque vous rejoignez un jeu multimonde, il vous sera demandé de fournir votre fichier YAML à l'hébergeur. Une fois cela fait, -l'hébergeur vous fournira soit un lien pour télécharger votre fichier de données, soit un fichier zip contenant les données de chacun -des dossiers. Votre fichier de données doit avoir une extension `.apmc`. - -Double-cliquez sur votre fichier `.apmc` pour que le client Minecraft lance automatiquement le serveur forge installé. Assurez-vous de -laissez cette fenêtre ouverte car il s'agit de votre console serveur. - -### Connectez-vous au multiserveur - -Ouvrez Minecraft, accédez à "Multijoueur> Connexion directe" et rejoignez l'adresse du serveur "localhost". - -Si vous utilisez le site Web pour héberger le jeu, il devrait se connecter automatiquement au serveur AP sans avoir besoin de `/connect` - -sinon, une fois que vous êtes dans le jeu, tapez `/connect (Port) (Password)` où `` est l'adresse du -Serveur Archipelago. `(Port)` n'est requis que si le serveur Archipelago n'utilise pas le port par défaut 38281. Notez qu'il n'y a pas de deux-points entre `` et `(Port)` mais un espace. -`(Mot de passe)` n'est requis que si le serveur Archipelago que vous utilisez a un mot de passe défini. - -### Jouer le jeu - -Lorsque la console vous indique que vous avez rejoint la salle, vous êtes prêt. Félicitations pour avoir rejoint avec succès un -jeu multimonde ! À ce stade, tous les joueurs minecraft supplémentaires peuvent se connecter à votre serveur forge. Pour commencer le jeu une fois -que tout le monde est prêt utilisez la commande `/start`. - -## Installation non Windows - -Le client Minecraft installera forge et le mod pour d'autres systèmes d'exploitation, mais Java doit être fourni par l' -utilisateur. Rendez-vous sur [minecraft_versions.json sur le MC AP GitHub](https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json) -pour voir quelle version de Java est requise. Les nouvelles installations utiliseront par défaut la version "release" la plus élevée. -- Installez le JDK Amazon Corretto correspondant - - voir les [Liens d'installation manuelle du logiciel](#manual-installation-software-links) - - ou gestionnaire de paquets fourni par votre OS/distribution -- Ouvrez votre `host.yaml` et ajoutez le chemin vers votre Java sous la clé `minecraft_options` - - ` java : "chemin/vers/java-xx-amazon-corretto/bin/java"` -- Exécutez le client Minecraft et sélectionnez votre fichier .apmc - -## Installation manuelle complète - -Il est fortement recommandé d'utiliser le programme d'installation d'Archipelago pour gérer l'installation du serveur forge pour vous. -Le support ne sera pas fourni pour ceux qui souhaitent installer manuellement forge. Pour ceux d'entre vous qui savent comment faire et qui souhaitent le faire, -les liens suivants sont les versions des logiciels que nous utilisons. - -### Liens d'installation manuelle du logiciel - -- [Page de téléchargement de Minecraft Forge] (https://files.minecraftforge.net/net/minecraftforge/forge/) -- [Page des versions du mod Minecraft Archipelago Randomizer] (https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases) - - **NE PAS INSTALLER CECI SUR VOTRE CLIENT** -- [Amazon Corretto](https://docs.aws.amazon.com/corretto/) - - choisissez la version correspondante et sélectionnez "Téléchargements" sur la gauche diff --git a/worlds/minecraft/docs/minecraft_sv.md b/worlds/minecraft/docs/minecraft_sv.md deleted file mode 100644 index ab8c1b5d8e..0000000000 --- a/worlds/minecraft/docs/minecraft_sv.md +++ /dev/null @@ -1,132 +0,0 @@ -# Minecraft Randomizer Uppsättningsguide - -## Nödvändig Mjukvara - -### Server Värd - -- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html) -- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases) - -### Spelare - -- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) - -## Installationsprocedurer - -### Tillägnad - -Bara en person behöver göra denna uppsättning och vara värd för en server för alla andra spelare att koppla till. - -1. Ladda ner 1.16.5 **Minecraft Forge** installeraren från länken ovanför och se till att ladda ner den senaste - rekommenderade versionen. - -2. Kör `forge-1.16.5-xx.x.x-installer.jar` filen och välj **installera server**. - - På denna sida kommer du också välja vart du ska installera servern för att komma ihåg denna katalog. Detta är - viktigt för nästa steg. - -3. Navigera till vart du har installerat servern och öppna `forge-1.16.5-xx.x.x-installer.jar` - - Under första serverstart så kommer den att stängas ner och fråga dig att acceptera Minecrafts EULA. En ny fil - kommer skapas vid namn `eula.txt` som har en länk till Minecrafts EULA, och en linje som du behöver byta - till `eula=true` för att acceptera Minecrafts EULA. - - Detta kommer skapa de lämpliga katalogerna för dig att placera filerna i de följande steget. - -4. Placera `aprandomizer-x.x.x.jar` länken ovanför i `mods` mappen som ligger ovanför installationen av din forge - server. - - Kör servern igen. Den kommer ladda up och generera den nödvändiga katalogen `APData` för när du är redo att spela! - -### Grundläggande Spelaruppsättning - -- Köp och installera Minecraft från länken ovanför. - - **Du är klar**. - - Andra spelare behöver endast ha en 'Vanilla' omodifierad version av Minecraft för att kunna spela! - -### Avancerad Spelaruppsättning - -***Detta är inte nödvändigt för att spela ett slumpmässigt Minecraftspel.*** -Dock så är det rekommenderat eftersom det hjälper att göra upplevelsen mer trevligt. - -#### Rekommenderade Moddar - -- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap) - - -1. Installera och Kör Minecraft från länken ovanför minst en gång. -2. Kör `forge-1.16.5-xx.x.x-installer.jar` filen och välj **installera klient**. - - Starta Minecraft Forge minst en gång för att skapa katalogerna som behövs för de nästa stegen. -3. Navigera till din Minecraft installationskatalog och placera de önskade moddarna med `.jar` i `mods` -katalogen. - - Standardinstallationskatalogerna är som följande; - - Windows `%APPDATA%\.minecraft\mods` - - macOS `~/Library/Application Support/minecraft/mods` - - Linux `~/.minecraft/mods` - -## Konfigurera Din YAML-fil - -### Vad är en YAML-fil och varför behöver jag en? - -Din YAML-fil behåller en uppsättning av konfigurationsalternativ som ger generatorn med information om hur den borde -generera ditt spel. Varje spelare i en multivärld kommer behöva ge deras egen YAML-fil. Denna uppsättning tillåter varje -spelare att an njuta av en upplevelse anpassade för deras smaker, och olika spelare i samma multivärld kan ha helt olika -alternativ. - -### Vart kan jag få tag i en YAML-fil? - -En grundläggande Minecraft YAML kommer se ut så här. - -```yaml -description: Template Name -# Ditt spelnamn. Mellanslag kommer bli omplacerad med understräck och det är en 16-karaktärsgräns. -name: YourName -game: Minecraft -accessibility: full -progression_balancing: 0 -advancement_goal: - few: 0 - normal: 1 - many: 0 -combat_difficulty: - easy: 0 - normal: 1 - hard: 0 -include_hard_advancements: - on: 0 - off: 1 -include_insane_advancements: - on: 0 - off: 1 -include_postgame_advancements: - on: 0 - off: 1 -shuffle_structures: - on: 1 - off: 0 -``` - - -## Gå med i ett Multivärld-spel - -### Skaffa din Minecraft data-fil - -**Endast en YAML-fil behöver användats per Minecraft-värld oavsett hur många spelare det är som spelar.** - -När du går med it ett Multivärld spel så kommer du bli ombedd att lämna in din YAML-fil till personen som värdar. När -detta är klart så kommer värden att ge dig antingen en länk till att ladda ner din data-fil, eller mer en zip-fil som -innehåller allas data-filer. Din data-fil borde ha en `.apmc` -extension. - -Lägg din data-fil i dina forge-servrar `APData` -mapp. Se till att ta bort alla tidigare data-filer som var i där förut. - -### Koppla till Multiservern - -Efter du har placerat din data-fil i `APData` -mappen, starta forge-servern och se till att you har OP-status genom att -skriva `/op DittAnvändarnamn` i forger-serverns konsol innan du kopplar dig till din Minecraft klient. När du är inne i -spelet, skriv `/connect ()` där `` är addressen av -Archipelago-servern. `()` är endast nödvändigt om Archipelago-servern som du använder har ett tillsatt -lösenord. - -### Spela spelet - -När konsolen har informerat att du har gått med i rummet så är du redo att börja spela. Grattis att du har lykats med -att gått med i ett Multivärld-spel! Vid detta tillfälle, alla ytterligare Minecraft-spelare må koppla in till din -forge-server. - diff --git a/worlds/minecraft/requirements.txt b/worlds/minecraft/requirements.txt deleted file mode 100644 index 85fe230fe5..0000000000 --- a/worlds/minecraft/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests >= 2.28.1 # used by client diff --git a/worlds/minecraft/test/TestAdvancements.py b/worlds/minecraft/test/TestAdvancements.py deleted file mode 100644 index 321aef1af9..0000000000 --- a/worlds/minecraft/test/TestAdvancements.py +++ /dev/null @@ -1,1410 +0,0 @@ -from . import MCTestBase - - -# Format: -# [location, expected_result, given_items, [excluded_items]] -# Every advancement has its own test, named by its internal ID number. -class TestAdvancements(MCTestBase): - options = { - "shuffle_structures": False, - "structure_compasses": False - } - - def test_42000(self): - self.run_location_tests([ - ["Who is Cutting Onions?", False, []], - ["Who is Cutting Onions?", False, [], ['Progressive Resource Crafting']], - ["Who is Cutting Onions?", False, [], ['Flint and Steel']], - ["Who is Cutting Onions?", False, [], ['Progressive Tools']], - ["Who is Cutting Onions?", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Who is Cutting Onions?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["Who is Cutting Onions?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42001(self): - self.run_location_tests([ - ["Oh Shiny", False, []], - ["Oh Shiny", False, [], ['Progressive Resource Crafting']], - ["Oh Shiny", False, [], ['Flint and Steel']], - ["Oh Shiny", False, [], ['Progressive Tools']], - ["Oh Shiny", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Oh Shiny", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["Oh Shiny", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42002(self): - self.run_location_tests([ - ["Suit Up", False, []], - ["Suit Up", False, [], ["Progressive Armor"]], - ["Suit Up", False, [], ["Progressive Resource Crafting"]], - ["Suit Up", False, [], ["Progressive Tools"]], - ["Suit Up", True, ["Progressive Armor", "Progressive Resource Crafting", "Progressive Tools"]], - ]) - - def test_42003(self): - self.run_location_tests([ - ["Very Very Frightening", False, []], - ["Very Very Frightening", False, [], ['Channeling Book']], - ["Very Very Frightening", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Very Very Frightening", False, [], ['Enchanting']], - ["Very Very Frightening", False, [], ['Progressive Tools']], - ["Very Very Frightening", False, [], ['Progressive Weapons']], - ["Very Very Frightening", True, ['Progressive Weapons', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Enchanting', 'Progressive Resource Crafting', 'Progressive Resource Crafting', 'Channeling Book']], - ]) - - def test_42004(self): - self.run_location_tests([ - ["Hot Stuff", False, []], - ["Hot Stuff", False, [], ["Bucket"]], - ["Hot Stuff", False, [], ["Progressive Resource Crafting"]], - ["Hot Stuff", False, [], ["Progressive Tools"]], - ["Hot Stuff", True, ["Bucket", "Progressive Resource Crafting", "Progressive Tools"]], - ]) - - def test_42005(self): - self.run_location_tests([ - ["Free the End", False, []], - ["Free the End", False, [], ['Progressive Resource Crafting']], - ["Free the End", False, [], ['Flint and Steel']], - ["Free the End", False, [], ['Progressive Tools']], - ["Free the End", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Free the End", False, [], ['Progressive Armor']], - ["Free the End", False, [], ['Brewing']], - ["Free the End", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Free the End", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["Free the End", False, [], ['Archery']], - ["Free the End", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Free the End", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42006(self): - self.run_location_tests([ - ["A Furious Cocktail", False, []], - ["A Furious Cocktail", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["A Furious Cocktail", False, [], ['Flint and Steel']], - ["A Furious Cocktail", False, [], ['Progressive Tools']], - ["A Furious Cocktail", False, [], ['Progressive Weapons']], - ["A Furious Cocktail", False, [], ['Progressive Armor', 'Shield']], - ["A Furious Cocktail", False, [], ['Brewing']], - ["A Furious Cocktail", False, [], ['Bottles']], - ["A Furious Cocktail", False, [], ['Fishing Rod']], - ["A Furious Cocktail", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["A Furious Cocktail", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Progressive Weapons', - 'Progressive Armor', 'Progressive Armor', - 'Enchanting', 'Brewing', 'Bottles', 'Fishing Rod']], - ]) - - def test_42007(self): - self.run_location_tests([ - ["Best Friends Forever", True, []], - ]) - - def test_42008(self): - self.run_location_tests([ - ["Bring Home the Beacon", False, []], - ["Bring Home the Beacon", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Bring Home the Beacon", False, [], ['Flint and Steel']], - ["Bring Home the Beacon", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Bring Home the Beacon", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Bring Home the Beacon", False, ['Progressive Armor'], ['Progressive Armor']], - ["Bring Home the Beacon", False, [], ['Enchanting']], - ["Bring Home the Beacon", False, [], ['Brewing']], - ["Bring Home the Beacon", False, [], ['Bottles']], - ["Bring Home the Beacon", True, [], ['Bucket']], - ["Bring Home the Beacon", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Progressive Weapons', - 'Progressive Armor', 'Progressive Armor', - 'Enchanting', 'Brewing', 'Bottles']], - ]) - - def test_42009(self): - self.run_location_tests([ - ["Not Today, Thank You", False, []], - ["Not Today, Thank You", False, [], ["Shield"]], - ["Not Today, Thank You", False, [], ["Progressive Resource Crafting"]], - ["Not Today, Thank You", False, [], ["Progressive Tools"]], - ["Not Today, Thank You", True, ["Shield", "Progressive Resource Crafting", "Progressive Tools"]], - ]) - - def test_42010(self): - self.run_location_tests([ - ["Isn't It Iron Pick", False, []], - ["Isn't It Iron Pick", True, ["Progressive Tools", "Progressive Tools"], ["Progressive Tools"]], - ["Isn't It Iron Pick", False, [], ["Progressive Tools", "Progressive Tools"]], - ["Isn't It Iron Pick", False, [], ["Progressive Resource Crafting"]], - ["Isn't It Iron Pick", False, ["Progressive Tools", "Progressive Resource Crafting"]], - ["Isn't It Iron Pick", True, ["Progressive Tools", "Progressive Tools", "Progressive Resource Crafting"]], - ]) - - def test_42011(self): - self.run_location_tests([ - ["Local Brewery", False, []], - ["Local Brewery", False, [], ['Progressive Resource Crafting']], - ["Local Brewery", False, [], ['Flint and Steel']], - ["Local Brewery", False, [], ['Progressive Tools']], - ["Local Brewery", False, [], ['Progressive Weapons']], - ["Local Brewery", False, [], ['Progressive Armor', 'Shield']], - ["Local Brewery", False, [], ['Brewing']], - ["Local Brewery", False, [], ['Bottles']], - ["Local Brewery", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Local Brewery", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles']], - ["Local Brewery", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles']], - ["Local Brewery", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles']], - ["Local Brewery", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles']], - ]) - - def test_42012(self): - self.run_location_tests([ - ["The Next Generation", False, []], - ["The Next Generation", False, [], ['Progressive Resource Crafting']], - ["The Next Generation", False, [], ['Flint and Steel']], - ["The Next Generation", False, [], ['Progressive Tools']], - ["The Next Generation", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["The Next Generation", False, [], ['Progressive Armor']], - ["The Next Generation", False, [], ['Brewing']], - ["The Next Generation", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["The Next Generation", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["The Next Generation", False, [], ['Archery']], - ["The Next Generation", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The Next Generation", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42013(self): - self.run_location_tests([ - ["Fishy Business", False, []], - ["Fishy Business", False, [], ['Fishing Rod']], - ["Fishy Business", True, ['Fishing Rod']], - ]) - - def test_42014(self): - self.run_location_tests([ - ["Hot Tourist Destinations", False, []], - ["Hot Tourist Destinations", False, [], ['Progressive Resource Crafting']], - ["Hot Tourist Destinations", False, [], ['Flint and Steel']], - ["Hot Tourist Destinations", False, [], ['Progressive Tools']], - ["Hot Tourist Destinations", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Hot Tourist Destinations", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["Hot Tourist Destinations", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42015(self): - self.run_location_tests([ - ["This Boat Has Legs", False, []], - ["This Boat Has Legs", False, [], ['Progressive Resource Crafting']], - ["This Boat Has Legs", False, [], ['Flint and Steel']], - ["This Boat Has Legs", False, [], ['Progressive Tools']], - ["This Boat Has Legs", False, [], ['Progressive Weapons']], - ["This Boat Has Legs", False, [], ['Progressive Armor', 'Shield']], - ["This Boat Has Legs", False, [], ['Fishing Rod']], - ["This Boat Has Legs", False, [], ['Saddle']], - ["This Boat Has Legs", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["This Boat Has Legs", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor', 'Flint and Steel', 'Bucket', 'Fishing Rod']], - ["This Boat Has Legs", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Fishing Rod']], - ["This Boat Has Legs", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Weapons', 'Shield', 'Flint and Steel', 'Bucket', 'Fishing Rod']], - ["This Boat Has Legs", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Weapons', 'Shield', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Fishing Rod']], - ]) - - def test_42016(self): - self.run_location_tests([ - ["Sniper Duel", False, []], - ["Sniper Duel", False, [], ['Archery']], - ["Sniper Duel", True, ['Archery']], - ]) - - def test_42017(self): - self.run_location_tests([ - ["Nether", False, []], - ["Nether", False, [], ['Progressive Resource Crafting']], - ["Nether", False, [], ['Flint and Steel']], - ["Nether", False, [], ['Progressive Tools']], - ["Nether", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Nether", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["Nether", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42018(self): - self.run_location_tests([ - ["Great View From Up Here", False, []], - ["Great View From Up Here", False, [], ['Progressive Resource Crafting']], - ["Great View From Up Here", False, [], ['Flint and Steel']], - ["Great View From Up Here", False, [], ['Progressive Tools']], - ["Great View From Up Here", False, [], ['Progressive Weapons']], - ["Great View From Up Here", False, [], ['Progressive Armor', 'Shield']], - ["Great View From Up Here", False, [], ['Brewing']], - ["Great View From Up Here", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Great View From Up Here", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["Great View From Up Here", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Great View From Up Here", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Great View From Up Here", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Great View From Up Here", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42019(self): - self.run_location_tests([ - ["How Did We Get Here?", False, []], - ["How Did We Get Here?", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["How Did We Get Here?", False, [], ['Flint and Steel']], - ["How Did We Get Here?", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["How Did We Get Here?", False, ['Progressive Weapons', 'Progressive Weapons'], ['Progressive Weapons']], - ["How Did We Get Here?", False, ['Progressive Armor'], ['Progressive Armor']], - ["How Did We Get Here?", False, [], ['Shield']], - ["How Did We Get Here?", False, [], ['Enchanting']], - ["How Did We Get Here?", False, [], ['Brewing']], - ["How Did We Get Here?", False, [], ['Bottles']], - ["How Did We Get Here?", False, [], ['Archery']], - ["How Did We Get Here?", False, [], ['Fishing Rod']], - ["How Did We Get Here?", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["How Did We Get Here?", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', 'Flint and Steel', - 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Progressive Weapons', - 'Progressive Armor', 'Progressive Armor', 'Shield', - 'Enchanting', 'Brewing', 'Archery', 'Bottles', 'Fishing Rod', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42020(self): - self.run_location_tests([ - ["Bullseye", False, []], - ["Bullseye", False, [], ['Archery']], - ["Bullseye", False, [], ['Progressive Resource Crafting']], - ["Bullseye", False, [], ['Progressive Tools']], - ["Bullseye", True, ['Progressive Tools', 'Progressive Tools', 'Progressive Resource Crafting', 'Archery']], - ]) - - def test_42021(self): - self.run_location_tests([ - ["Spooky Scary Skeleton", False, []], - ["Spooky Scary Skeleton", False, [], ['Progressive Resource Crafting']], - ["Spooky Scary Skeleton", False, [], ['Flint and Steel']], - ["Spooky Scary Skeleton", False, [], ['Progressive Tools']], - ["Spooky Scary Skeleton", False, [], ['Progressive Weapons']], - ["Spooky Scary Skeleton", False, [], ['Progressive Armor', 'Shield']], - ["Spooky Scary Skeleton", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Spooky Scary Skeleton", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor']], - ["Spooky Scary Skeleton", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor']], - ["Spooky Scary Skeleton", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield']], - ["Spooky Scary Skeleton", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Shield']], - ]) - - def test_42022(self): - self.run_location_tests([ - ["Two by Two", False, []], - ["Two by Two", False, [], ['Progressive Resource Crafting']], - ["Two by Two", False, [], ['Flint and Steel']], - ["Two by Two", False, [], ['Progressive Tools']], - ["Two by Two", False, [], ['Progressive Weapons']], - ["Two by Two", False, [], ['Bucket']], - ["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Two by Two", False, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], - ["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons']], - ]) - - def test_42023(self): - self.run_location_tests([ - ["Stone Age", True, []], - ]) - - def test_42024(self): - self.run_location_tests([ - ["Two Birds, One Arrow", False, []], - ["Two Birds, One Arrow", False, [], ['Archery']], - ["Two Birds, One Arrow", False, [], ['Progressive Resource Crafting']], - ["Two Birds, One Arrow", False, ['Progressive Tools'], ['Progressive Tools', 'Progressive Tools']], - ["Two Birds, One Arrow", False, [], ['Enchanting']], - ["Two Birds, One Arrow", True, ['Archery', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Enchanting']], - ]) - - def test_42025(self): - self.run_location_tests([ - ["We Need to Go Deeper", False, []], - ["We Need to Go Deeper", False, [], ['Progressive Resource Crafting']], - ["We Need to Go Deeper", False, [], ['Flint and Steel']], - ["We Need to Go Deeper", False, [], ['Progressive Tools']], - ["We Need to Go Deeper", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["We Need to Go Deeper", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["We Need to Go Deeper", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42026(self): - self.run_location_tests([ - ["Who's the Pillager Now?", False, []], - ["Who's the Pillager Now?", False, [], ['Archery']], - ["Who's the Pillager Now?", False, [], ['Progressive Resource Crafting']], - ["Who's the Pillager Now?", False, [], ['Progressive Tools']], - ["Who's the Pillager Now?", False, [], ['Progressive Weapons']], - ["Who's the Pillager Now?", True, ['Archery', 'Progressive Tools', 'Progressive Weapons', 'Progressive Resource Crafting']], - ]) - - def test_42027(self): - self.run_location_tests([ - ["Getting an Upgrade", False, []], - ["Getting an Upgrade", True, ["Progressive Tools"]], - ]) - - def test_42028(self): - self.run_location_tests([ - ["Tactical Fishing", False, []], - ["Tactical Fishing", False, [], ['Progressive Resource Crafting']], - ["Tactical Fishing", False, [], ['Progressive Tools']], - ["Tactical Fishing", False, [], ['Bucket']], - ["Tactical Fishing", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Bucket']], - ]) - - def test_42029(self): - self.run_location_tests([ - ["Zombie Doctor", False, []], - ["Zombie Doctor", False, [], ['Progressive Resource Crafting']], - ["Zombie Doctor", False, [], ['Flint and Steel']], - ["Zombie Doctor", False, [], ['Progressive Tools']], - ["Zombie Doctor", False, [], ['Progressive Weapons']], - ["Zombie Doctor", False, [], ['Progressive Armor', 'Shield']], - ["Zombie Doctor", False, [], ['Brewing']], - ["Zombie Doctor", False, [], ['Bottles']], - ["Zombie Doctor", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Zombie Doctor", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles']], - ["Zombie Doctor", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles']], - ["Zombie Doctor", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles']], - ["Zombie Doctor", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles']], - ]) - - def test_42030(self): - self.run_location_tests([ - ["The City at the End of the Game", False, []], - ["The City at the End of the Game", False, [], ['Progressive Resource Crafting']], - ["The City at the End of the Game", False, [], ['Flint and Steel']], - ["The City at the End of the Game", False, [], ['Progressive Tools']], - ["The City at the End of the Game", False, [], ['Progressive Weapons']], - ["The City at the End of the Game", False, [], ['Progressive Armor', 'Shield']], - ["The City at the End of the Game", False, [], ['Brewing']], - ["The City at the End of the Game", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["The City at the End of the Game", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["The City at the End of the Game", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The City at the End of the Game", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The City at the End of the Game", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The City at the End of the Game", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42031(self): - self.run_location_tests([ - ["Ice Bucket Challenge", False, []], - ["Ice Bucket Challenge", False, ["Progressive Tools", "Progressive Tools"], ["Progressive Tools"]], - ["Ice Bucket Challenge", False, [], ["Progressive Resource Crafting"]], - ["Ice Bucket Challenge", True, ["Progressive Tools", "Progressive Tools", "Progressive Tools", "Progressive Resource Crafting"]], - ]) - - def test_42032(self): - self.run_location_tests([ - ["Remote Getaway", False, []], - ["Remote Getaway", False, [], ['Progressive Resource Crafting']], - ["Remote Getaway", False, [], ['Flint and Steel']], - ["Remote Getaway", False, [], ['Progressive Tools']], - ["Remote Getaway", False, [], ['Progressive Weapons']], - ["Remote Getaway", False, [], ['Progressive Armor', 'Shield']], - ["Remote Getaway", False, [], ['Brewing']], - ["Remote Getaway", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Remote Getaway", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["Remote Getaway", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Remote Getaway", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Remote Getaway", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Remote Getaway", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42033(self): - self.run_location_tests([ - ["Into Fire", False, []], - ["Into Fire", False, [], ['Progressive Resource Crafting']], - ["Into Fire", False, [], ['Flint and Steel']], - ["Into Fire", False, [], ['Progressive Tools']], - ["Into Fire", False, [], ['Progressive Weapons']], - ["Into Fire", False, [], ['Progressive Armor', 'Shield']], - ["Into Fire", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Into Fire", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor']], - ["Into Fire", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor']], - ["Into Fire", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield']], - ["Into Fire", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Shield']], - ]) - - def test_42034(self): - self.run_location_tests([ - ["War Pigs", False, []], - ["War Pigs", False, [], ['Progressive Resource Crafting']], - ["War Pigs", False, [], ['Flint and Steel']], - ["War Pigs", False, [], ['Progressive Tools']], - ["War Pigs", False, [], ['Progressive Weapons']], - ["War Pigs", False, [], ['Progressive Armor', 'Shield']], - ["War Pigs", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["War Pigs", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield']], - ["War Pigs", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Shield']], - ]) - - def test_42035(self): - self.run_location_tests([ - ["Take Aim", False, []], - ["Take Aim", False, [], ['Archery']], - ["Take Aim", True, ['Archery']], - ]) - - def test_42036(self): - self.run_location_tests([ - ["Total Beelocation", False, []], - ["Total Beelocation", False, [], ['Enchanting']], - ["Total Beelocation", False, [], ['Silk Touch Book']], - ["Total Beelocation", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Total Beelocation", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Total Beelocation", True, ['Enchanting', 'Silk Touch Book', 'Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42037(self): - self.run_location_tests([ - ["Arbalistic", False, []], - ["Arbalistic", False, [], ['Enchanting']], - ["Arbalistic", False, [], ['Piercing IV Book']], - ["Arbalistic", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Arbalistic", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Arbalistic", False, [], ['Archery']], - ["Arbalistic", True, ['Enchanting', 'Piercing IV Book', 'Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Archery']], - ]) - - def test_42038(self): - self.run_location_tests([ - ["The End... Again...", False, []], - ["The End... Again...", False, [], ['Progressive Resource Crafting']], - ["The End... Again...", False, [], ['Flint and Steel']], - ["The End... Again...", False, [], ['Progressive Tools']], - ["The End... Again...", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["The End... Again...", False, [], ['Progressive Armor']], - ["The End... Again...", False, [], ['Brewing']], - ["The End... Again...", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["The End... Again...", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["The End... Again...", False, [], ['Archery']], - ["The End... Again...", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End... Again...", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42039(self): - self.run_location_tests([ - ["Acquire Hardware", False, []], - ["Acquire Hardware", False, [], ["Progressive Tools"]], - ["Acquire Hardware", False, [], ["Progressive Resource Crafting"]], - ["Acquire Hardware", True, ["Progressive Tools", "Progressive Resource Crafting"]], - ]) - - def test_42040(self): - self.run_location_tests([ - ["Not Quite \"Nine\" Lives", False, []], - ["Not Quite \"Nine\" Lives", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Not Quite \"Nine\" Lives", False, [], ['Flint and Steel']], - ["Not Quite \"Nine\" Lives", False, [], ['Progressive Tools']], - ["Not Quite \"Nine\" Lives", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Not Quite \"Nine\" Lives", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["Not Quite \"Nine\" Lives", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42041(self): - self.run_location_tests([ - ["Cover Me With Diamonds", False, []], - ["Cover Me With Diamonds", False, ['Progressive Armor'], ['Progressive Armor']], - ["Cover Me With Diamonds", False, ['Progressive Tools'], ['Progressive Tools', 'Progressive Tools']], - ["Cover Me With Diamonds", False, [], ['Progressive Resource Crafting']], - ["Cover Me With Diamonds", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Armor', 'Progressive Armor']], - ]) - - def test_42042(self): - self.run_location_tests([ - ["Sky's the Limit", False, []], - ["Sky's the Limit", False, [], ['Progressive Resource Crafting']], - ["Sky's the Limit", False, [], ['Flint and Steel']], - ["Sky's the Limit", False, [], ['Progressive Tools']], - ["Sky's the Limit", False, [], ['Progressive Weapons']], - ["Sky's the Limit", False, [], ['Progressive Armor', 'Shield']], - ["Sky's the Limit", False, [], ['Brewing']], - ["Sky's the Limit", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Sky's the Limit", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["Sky's the Limit", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Sky's the Limit", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Sky's the Limit", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["Sky's the Limit", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42043(self): - self.run_location_tests([ - ["Hired Help", False, []], - ["Hired Help", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Hired Help", False, [], ['Progressive Tools']], - ["Hired Help", True, ['Progressive Tools', 'Progressive Resource Crafting', 'Progressive Resource Crafting']], - ]) - - def test_42044(self): - self.run_location_tests([ - ["Return to Sender", False, []], - ["Return to Sender", False, [], ['Progressive Resource Crafting']], - ["Return to Sender", False, [], ['Flint and Steel']], - ["Return to Sender", False, [], ['Progressive Tools']], - ["Return to Sender", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Return to Sender", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket']], - ["Return to Sender", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42045(self): - self.run_location_tests([ - ["Sweet Dreams", False, []], - ["Sweet Dreams", True, ['Bed']], - ["Sweet Dreams", False, [], ['Bed', 'Progressive Weapons']], - ["Sweet Dreams", False, [], ['Bed', 'Progressive Resource Crafting', 'Campfire']], - ["Sweet Dreams", True, ['Progressive Weapons', 'Progressive Resource Crafting'], ['Bed', 'Campfire']], - ["Sweet Dreams", True, ['Progressive Weapons', 'Campfire'], ['Bed', 'Progressive Resource Crafting']], - ]) - - def test_42046(self): - self.run_location_tests([ - ["You Need a Mint", False, []], - ["You Need a Mint", False, [], ['Progressive Resource Crafting']], - ["You Need a Mint", False, [], ['Flint and Steel']], - ["You Need a Mint", False, [], ['Progressive Tools']], - ["You Need a Mint", False, [], ['Progressive Weapons']], - ["You Need a Mint", False, [], ['Progressive Armor', 'Shield']], - ["You Need a Mint", False, [], ['Brewing']], - ["You Need a Mint", False, [], ['Bottles']], - ["You Need a Mint", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["You Need a Mint", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']], - ["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']], - ["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']], - ["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']], - ]) - - def test_42047(self): - self.run_location_tests([ - ["Adventure", True, []], - ]) - - def test_42048(self): - self.run_location_tests([ - ["Monsters Hunted", False, []], - ["Monsters Hunted", False, [], ['Progressive Resource Crafting']], - ["Monsters Hunted", False, [], ['Flint and Steel']], - ["Monsters Hunted", False, [], ['Progressive Tools']], - ["Monsters Hunted", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Monsters Hunted", False, [], ['Progressive Armor']], - ["Monsters Hunted", False, [], ['Brewing']], - ["Monsters Hunted", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Monsters Hunted", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["Monsters Hunted", False, [], ['Archery']], - ["Monsters Hunted", False, [], ['Enchanting']], - ["Monsters Hunted", False, [], ['Fishing Rod']], - ["Monsters Hunted", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Progressive Weapons', 'Archery', - 'Progressive Armor', 'Progressive Armor', 'Enchanting', - 'Fishing Rod', 'Brewing', 'Bottles', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42049(self): - self.run_location_tests([ - ["Enchanter", False, []], - ["Enchanter", False, [], ['Enchanting']], - ["Enchanter", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Enchanter", False, [], ['Progressive Resource Crafting']], - ["Enchanter", True, ['Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Enchanting', 'Progressive Resource Crafting']], - ]) - - def test_42050(self): - self.run_location_tests([ - ["Voluntary Exile", False, []], - ["Voluntary Exile", False, [], ['Progressive Weapons']], - ["Voluntary Exile", False, [], ['Progressive Armor', 'Shield']], - ["Voluntary Exile", False, [], ['Progressive Tools']], - ["Voluntary Exile", False, [], ['Progressive Resource Crafting']], - ["Voluntary Exile", True, ['Progressive Tools', 'Progressive Armor', 'Progressive Weapons', 'Progressive Resource Crafting']], - ["Voluntary Exile", True, ['Progressive Tools', 'Shield', 'Progressive Weapons', 'Progressive Resource Crafting']], - ]) - - def test_42051(self): - self.run_location_tests([ - ["Eye Spy", False, []], - ["Eye Spy", False, [], ['Progressive Resource Crafting']], - ["Eye Spy", False, [], ['Flint and Steel']], - ["Eye Spy", False, [], ['Progressive Tools']], - ["Eye Spy", False, [], ['Progressive Weapons']], - ["Eye Spy", False, [], ['Progressive Armor', 'Shield']], - ["Eye Spy", False, [], ['Brewing']], - ["Eye Spy", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Eye Spy", False, [], ['3 Ender Pearls']], - ["Eye Spy", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', '3 Ender Pearls']], - ["Eye Spy", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', '3 Ender Pearls']], - ["Eye Spy", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Brewing', '3 Ender Pearls']], - ["Eye Spy", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', '3 Ender Pearls']], - ]) - - def test_42052(self): - self.run_location_tests([ - ["The End", False, []], - ["The End", False, [], ['Progressive Resource Crafting']], - ["The End", False, [], ['Flint and Steel']], - ["The End", False, [], ['Progressive Tools']], - ["The End", False, [], ['Progressive Weapons']], - ["The End", False, [], ['Progressive Armor', 'Shield']], - ["The End", False, [], ['Brewing']], - ["The End", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["The End", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["The End", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42053(self): - self.run_location_tests([ - ["Serious Dedication", False, []], - ["Serious Dedication", False, [], ['Progressive Resource Crafting']], - ["Serious Dedication", False, [], ['Flint and Steel']], - ["Serious Dedication", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Serious Dedication", False, [], ['Progressive Weapons']], - ["Serious Dedication", False, [], ['Progressive Armor', 'Shield']], - ["Serious Dedication", False, [], ['Brewing']], - ["Serious Dedication", False, [], ['Bottles']], - ["Serious Dedication", False, [], ['Bed']], - ["Serious Dedication", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles', 'Bed']], - ["Serious Dedication", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles', 'Bed']], - ]) - - def test_42054(self): - self.run_location_tests([ - ["Postmortal", False, []], - ["Postmortal", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Postmortal", False, [], ['Progressive Armor']], - ["Postmortal", False, [], ['Shield']], - ["Postmortal", False, [], ['Progressive Resource Crafting']], - ["Postmortal", False, [], ['Progressive Tools']], - ["Postmortal", True, ['Progressive Weapons', 'Progressive Weapons', 'Progressive Armor', 'Shield', 'Progressive Resource Crafting', 'Progressive Tools']], - ]) - - def test_42055(self): - self.run_location_tests([ - ["Monster Hunter", True, []], - ]) - - def test_42056(self): - self.run_location_tests([ - ["Adventuring Time", False, []], - ["Adventuring Time", False, [], ['Progressive Weapons']], - ["Adventuring Time", False, [], ['Campfire', 'Progressive Resource Crafting']], - ["Adventuring Time", True, ['Progressive Weapons', 'Campfire']], - ["Adventuring Time", True, ['Progressive Weapons', 'Progressive Resource Crafting']], - ]) - - def test_42057(self): - self.run_location_tests([ - ["A Seedy Place", True, []], - ]) - - def test_42058(self): - self.run_location_tests([ - ["Those Were the Days", False, []], - ["Those Were the Days", False, [], ['Progressive Resource Crafting']], - ["Those Were the Days", False, [], ['Flint and Steel']], - ["Those Were the Days", False, [], ['Progressive Tools']], - ["Those Were the Days", False, [], ['Progressive Weapons']], - ["Those Were the Days", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Those Were the Days", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons']], - ["Those Were the Days", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], - ]) - - def test_42059(self): - self.run_location_tests([ - ["Hero of the Village", False, []], - ["Hero of the Village", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Hero of the Village", False, [], ['Progressive Armor']], - ["Hero of the Village", False, [], ['Shield']], - ["Hero of the Village", False, [], ['Progressive Resource Crafting']], - ["Hero of the Village", False, [], ['Progressive Tools']], - ["Hero of the Village", True, ['Progressive Weapons', 'Progressive Weapons', 'Progressive Armor', 'Shield', 'Progressive Resource Crafting', 'Progressive Tools']], - ]) - - def test_42060(self): - self.run_location_tests([ - ["Hidden in the Depths", False, []], - ["Hidden in the Depths", False, [], ['Progressive Resource Crafting']], - ["Hidden in the Depths", False, [], ['Flint and Steel']], - ["Hidden in the Depths", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Hidden in the Depths", False, [], ['Progressive Weapons']], - ["Hidden in the Depths", False, [], ['Progressive Armor', 'Shield']], - ["Hidden in the Depths", False, [], ['Brewing']], - ["Hidden in the Depths", False, [], ['Bottles']], - ["Hidden in the Depths", False, [], ['Bed']], - ["Hidden in the Depths", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles', 'Bed']], - ["Hidden in the Depths", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles', 'Bed']], - ]) - - def test_42061(self): - self.run_location_tests([ - ["Beaconator", False, []], - ["Beaconator", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Beaconator", False, [], ['Flint and Steel']], - ["Beaconator", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Beaconator", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Beaconator", False, ['Progressive Armor'], ['Progressive Armor']], - ["Beaconator", False, [], ['Brewing']], - ["Beaconator", False, [], ['Bottles']], - ["Beaconator", False, [], ['Enchanting']], - ["Beaconator", True, [], ['Bucket']], - ["Beaconator", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Progressive Weapons', 'Progressive Armor', 'Progressive Armor', - 'Brewing', 'Bottles', 'Enchanting']], - ]) - - def test_42062(self): - self.run_location_tests([ - ["Withering Heights", False, []], - ["Withering Heights", False, [], ['Progressive Resource Crafting']], - ["Withering Heights", False, [], ['Flint and Steel']], - ["Withering Heights", False, [], ['Progressive Tools']], - ["Withering Heights", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']], - ["Withering Heights", False, ['Progressive Armor'], ['Progressive Armor']], - ["Withering Heights", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Withering Heights", False, [], ['Brewing']], - ["Withering Heights", False, [], ['Bottles']], - ["Withering Heights", False, [], ['Enchanting']], - ["Withering Heights", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Weapons', 'Progressive Weapons', 'Progressive Armor', 'Progressive Armor', - 'Brewing', 'Bottles', 'Enchanting']], - ]) - - def test_42063(self): - self.run_location_tests([ - ["A Balanced Diet", False, []], - ["A Balanced Diet", False, [], ['Bottles']], - ["A Balanced Diet", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["A Balanced Diet", False, [], ['Flint and Steel']], - ["A Balanced Diet", False, [], ['Progressive Tools']], - ["A Balanced Diet", False, [], ['Progressive Weapons']], - ["A Balanced Diet", False, [], ['Progressive Armor', 'Shield']], - ["A Balanced Diet", False, [], ['Brewing']], - ["A Balanced Diet", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["A Balanced Diet", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["A Balanced Diet", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Bottles', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["A Balanced Diet", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Bottles', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["A Balanced Diet", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Bottles', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["A Balanced Diet", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', - 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Bottles', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42064(self): - self.run_location_tests([ - ["Subspace Bubble", False, []], - ["Subspace Bubble", False, [], ['Progressive Resource Crafting']], - ["Subspace Bubble", False, [], ['Flint and Steel']], - ["Subspace Bubble", False, [], ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Subspace Bubble", True, ['Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Progressive Resource Crafting']], - ]) - - def test_42065(self): - self.run_location_tests([ - ["Husbandry", True, []], - ]) - - def test_42066(self): - self.run_location_tests([ - ["Country Lode, Take Me Home", False, []], - ["Country Lode, Take Me Home", False, [], ['Progressive Resource Crafting']], - ["Country Lode, Take Me Home", False, [], ['Flint and Steel']], - ["Country Lode, Take Me Home", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Country Lode, Take Me Home", False, [], ['Progressive Weapons']], - ["Country Lode, Take Me Home", False, [], ['Progressive Armor', 'Shield']], - ["Country Lode, Take Me Home", False, [], ['Brewing']], - ["Country Lode, Take Me Home", False, [], ['Bottles']], - ["Country Lode, Take Me Home", False, [], ['Bed']], - ["Country Lode, Take Me Home", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles', 'Bed']], - ["Country Lode, Take Me Home", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles', 'Bed']], - ]) - - def test_42067(self): - self.run_location_tests([ - ["Bee Our Guest", False, []], - ["Bee Our Guest", False, [], ['Campfire']], - ["Bee Our Guest", False, [], ['Bottles']], - ["Bee Our Guest", False, [], ['Progressive Resource Crafting']], - ["Bee Our Guest", True, ['Campfire', 'Bottles', 'Progressive Resource Crafting']], - ]) - - def test_42068(self): - self.run_location_tests([ - ["What a Deal!", False, []], - ["What a Deal!", False, [], ['Progressive Weapons']], - ["What a Deal!", False, [], ['Campfire', 'Progressive Resource Crafting']], - ["What a Deal!", True, ['Progressive Weapons', 'Campfire']], - ["What a Deal!", True, ['Progressive Weapons', 'Progressive Resource Crafting']], - ]) - - def test_42069(self): - self.run_location_tests([ - ["Uneasy Alliance", False, []], - ["Uneasy Alliance", False, [], ['Progressive Resource Crafting']], - ["Uneasy Alliance", False, [], ['Flint and Steel']], - ["Uneasy Alliance", False, [], ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Uneasy Alliance", False, [], ['Fishing Rod']], - ["Uneasy Alliance", True, ['Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Progressive Resource Crafting', 'Fishing Rod']], - ]) - - def test_42070(self): - self.run_location_tests([ - ["Diamonds!", False, []], - ["Diamonds!", True, ["Progressive Tools", "Progressive Tools"], ["Progressive Tools"]], - ["Diamonds!", False, [], ["Progressive Tools", "Progressive Tools"]], - ["Diamonds!", False, [], ["Progressive Resource Crafting"]], - ["Diamonds!", False, ["Progressive Tools", "Progressive Resource Crafting"]], - ["Diamonds!", True, ["Progressive Tools", "Progressive Tools", "Progressive Resource Crafting"]], - ]) - - def test_42071(self): - self.run_location_tests([ - ["A Terrible Fortress", False, []], - ["A Terrible Fortress", False, [], ['Progressive Resource Crafting']], - ["A Terrible Fortress", False, [], ['Flint and Steel']], - ["A Terrible Fortress", False, [], ['Progressive Tools']], - ["A Terrible Fortress", False, [], ['Progressive Weapons']], - ["A Terrible Fortress", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["A Terrible Fortress", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons']], - ["A Terrible Fortress", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], - ]) - - def test_42072(self): - self.run_location_tests([ - ["A Throwaway Joke", False, []], - ["A Throwaway Joke", False, [], ['Progressive Weapons']], - ["A Throwaway Joke", False, [], ['Campfire', 'Progressive Resource Crafting']], - ["A Throwaway Joke", True, ['Progressive Weapons', 'Campfire']], - ["A Throwaway Joke", True, ['Progressive Weapons', 'Progressive Resource Crafting']], - ]) - - def test_42073(self): - self.run_location_tests([ - ["Minecraft", True, []], - ]) - - def test_42074(self): - self.run_location_tests([ - ["Sticky Situation", False, []], - ["Sticky Situation", False, [], ['Bottles']], - ["Sticky Situation", False, [], ['Progressive Resource Crafting']], - ["Sticky Situation", False, [], ['Campfire']], - ["Sticky Situation", True, ['Bottles', 'Progressive Resource Crafting', 'Campfire']], - ]) - - def test_42075(self): - self.run_location_tests([ - ["Ol' Betsy", False, []], - ["Ol' Betsy", False, [], ['Archery']], - ["Ol' Betsy", False, [], ['Progressive Resource Crafting']], - ["Ol' Betsy", False, [], ['Progressive Tools']], - ["Ol' Betsy", True, ['Archery', 'Progressive Resource Crafting', 'Progressive Tools']], - ]) - - def test_42076(self): - self.run_location_tests([ - ["Cover Me in Debris", False, []], - ["Cover Me in Debris", False, [], ['Progressive Resource Crafting']], - ["Cover Me in Debris", False, [], ['Flint and Steel']], - ["Cover Me in Debris", False, ['Progressive Tools', 'Progressive Tools'], ['Progressive Tools']], - ["Cover Me in Debris", False, [], ['Progressive Weapons']], - ["Cover Me in Debris", False, ['Progressive Armor'], ['Progressive Armor']], - ["Cover Me in Debris", False, [], ['Brewing']], - ["Cover Me in Debris", False, [], ['Bottles']], - ["Cover Me in Debris", False, [], ['Bed']], - ["Cover Me in Debris", False, ['8 Netherite Scrap'], ['8 Netherite Scrap']], - ["Cover Me in Debris", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Progressive Armor', - 'Brewing', 'Bottles', 'Bed', '8 Netherite Scrap', '8 Netherite Scrap']], - ]) - - def test_42077(self): - self.run_location_tests([ - ["The End?", False, []], - ["The End?", False, [], ['Progressive Resource Crafting']], - ["The End?", False, [], ['Flint and Steel']], - ["The End?", False, [], ['Progressive Tools']], - ["The End?", False, [], ['Progressive Weapons']], - ["The End?", False, [], ['Progressive Armor', 'Shield']], - ["The End?", False, [], ['Brewing']], - ["The End?", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["The End?", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["The End?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ["The End?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def test_42078(self): - self.run_location_tests([ - ["The Parrots and the Bats", True, []], - ]) - - def test_42079(self): - self.run_location_tests([ - ["A Complete Catalogue", False, []], - ["A Complete Catalogue", False, [], ['Progressive Weapons']], - ["A Complete Catalogue", False, [], ['Campfire', 'Progressive Resource Crafting']], - ["A Complete Catalogue", True, ['Progressive Weapons', 'Campfire']], - ["A Complete Catalogue", True, ['Progressive Weapons', 'Progressive Resource Crafting']], - ]) - - def test_42080(self): - self.run_location_tests([ - ["Getting Wood", True, []], - ]) - - def test_42081(self): - self.run_location_tests([ - ["Time to Mine!", True, []], - ]) - - def test_42082(self): - self.run_location_tests([ - ["Hot Topic", False, []], - ["Hot Topic", True, ['Progressive Resource Crafting']], - ]) - - def test_42083(self): - self.run_location_tests([ - ["Bake Bread", True, []], - ]) - - def test_42084(self): - self.run_location_tests([ - ["The Lie", False, []], - ["The Lie", False, [], ['Progressive Resource Crafting']], - ["The Lie", False, [], ['Bucket']], - ["The Lie", False, [], ['Progressive Tools']], - ["The Lie", True, ['Bucket', 'Progressive Resource Crafting', 'Progressive Tools']], - ]) - - def test_42085(self): - self.run_location_tests([ - ["On a Rail", False, []], - ["On a Rail", False, [], ['Progressive Resource Crafting']], - ["On a Rail", False, ['Progressive Tools'], ['Progressive Tools', 'Progressive Tools']], - ["On a Rail", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools']], - ]) - - def test_42086(self): - self.run_location_tests([ - ["Time to Strike!", True, []], - ]) - - def test_42087(self): - self.run_location_tests([ - ["Cow Tipper", True, []], - ]) - - def test_42088(self): - self.run_location_tests([ - ["When Pigs Fly", False, []], - ["When Pigs Fly", False, [], ['Progressive Resource Crafting']], - ["When Pigs Fly", False, [], ['Progressive Tools']], - ["When Pigs Fly", False, [], ['Progressive Weapons']], - ["When Pigs Fly", False, [], ['Progressive Armor', 'Shield']], - ["When Pigs Fly", False, [], ['Fishing Rod']], - ["When Pigs Fly", False, [], ['Saddle']], - ["When Pigs Fly", False, ['Progressive Weapons'], ['Flint and Steel', 'Progressive Weapons', 'Progressive Weapons']], - ["When Pigs Fly", False, ['Progressive Tools', 'Progressive Tools', 'Progressive Weapons'], ['Bucket', 'Progressive Tools', 'Progressive Weapons', 'Progressive Weapons']], - ["When Pigs Fly", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor', 'Fishing Rod']], - ["When Pigs Fly", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor', 'Fishing Rod']], - ["When Pigs Fly", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield', 'Fishing Rod']], - ["When Pigs Fly", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Shield', 'Fishing Rod']], - ["When Pigs Fly", True, ['Saddle', 'Progressive Weapons', 'Progressive Weapons', 'Progressive Armor', 'Shield', 'Progressive Resource Crafting', 'Progressive Tools', 'Fishing Rod']], - ]) - - def test_42089(self): - self.run_location_tests([ - ["Overkill", False, []], - ["Overkill", False, [], ['Progressive Resource Crafting']], - ["Overkill", False, [], ['Flint and Steel']], - ["Overkill", False, [], ['Progressive Tools']], - ["Overkill", False, [], ['Progressive Weapons']], - ["Overkill", False, [], ['Progressive Armor', 'Shield']], - ["Overkill", False, [], ['Brewing']], - ["Overkill", False, [], ['Bottles']], - ["Overkill", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Overkill", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles']], - ["Overkill", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', 'Bottles']], - ["Overkill", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles']], - ["Overkill", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', 'Bottles']], - ]) - - def test_42090(self): - self.run_location_tests([ - ["Librarian", False, []], - ["Librarian", True, ['Enchanting']], - ]) - - def test_42091(self): - self.run_location_tests([ - ["Overpowered", False, []], - ["Overpowered", False, [], ['Progressive Resource Crafting']], - ["Overpowered", False, [], ['Flint and Steel']], - ["Overpowered", False, ['Progressive Tools', 'Progressive Tools', 'Bucket', 'Flint and Steel']], - ["Overpowered", False, [], ['Progressive Weapons']], - ["Overpowered", False, [], ['Progressive Armor', 'Shield']], - ["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor']], - ["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor']], - ["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Weapons', 'Shield']], - ["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield']], - ]) - - def test_42092(self): - self.run_location_tests([ - ["Wax On", False, []], - ["Wax On", False, [], ["Progressive Tools"]], - ["Wax On", False, [], ["Campfire"]], - ["Wax On", False, ["Progressive Resource Crafting"], ["Progressive Resource Crafting"]], - ["Wax On", True, ["Progressive Tools", "Progressive Resource Crafting", "Progressive Resource Crafting", "Campfire"]], - ]) - - def test_42093(self): - self.run_location_tests([ - ["Wax Off", False, []], - ["Wax Off", False, [], ["Progressive Tools"]], - ["Wax Off", False, [], ["Campfire"]], - ["Wax Off", False, ["Progressive Resource Crafting"], ["Progressive Resource Crafting"]], - ["Wax Off", True, ["Progressive Tools", "Progressive Resource Crafting", "Progressive Resource Crafting", "Campfire"]], - ]) - - def test_42094(self): - self.run_location_tests([ - ["The Cutest Predator", False, []], - ["The Cutest Predator", False, [], ["Progressive Tools"]], - ["The Cutest Predator", False, [], ["Progressive Resource Crafting"]], - ["The Cutest Predator", False, [], ["Bucket"]], - ["The Cutest Predator", True, ["Progressive Tools", "Progressive Resource Crafting", "Bucket"]], - ]) - - def test_42095(self): - self.run_location_tests([ - ["The Healing Power of Friendship", False, []], - ["The Healing Power of Friendship", False, [], ["Progressive Tools"]], - ["The Healing Power of Friendship", False, [], ["Progressive Resource Crafting"]], - ["The Healing Power of Friendship", False, [], ["Bucket"]], - ["The Healing Power of Friendship", True, ["Progressive Tools", "Progressive Resource Crafting", "Bucket"]], - ]) - - def test_42096(self): - self.run_location_tests([ - ["Is It a Bird?", False, []], - ["Is It a Bird?", False, [], ["Progressive Weapons"]], - ["Is It a Bird?", False, [], ["Progressive Tools"]], - ["Is It a Bird?", False, [], ["Progressive Resource Crafting"]], - ["Is It a Bird?", False, [], ["Spyglass"]], - ["Is It a Bird?", True, ["Progressive Weapons", "Progressive Tools", "Progressive Resource Crafting", "Spyglass"]], - ]) - - def test_42097(self): - self.run_location_tests([ - ["Is It a Balloon?", False, []], - ["Is It a Balloon?", False, [], ['Progressive Resource Crafting']], - ["Is It a Balloon?", False, [], ['Flint and Steel']], - ["Is It a Balloon?", False, [], ['Progressive Tools']], - ["Is It a Balloon?", False, [], ['Progressive Weapons']], - ["Is It a Balloon?", False, [], ['Spyglass']], - ["Is It a Balloon?", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Is It a Balloon?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Spyglass']], - ["Is It a Balloon?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Spyglass']], - ]) - - def test_42098(self): - self.run_location_tests([ - ["Is It a Plane?", False, []], - ["Is It a Plane?", False, [], ['Progressive Resource Crafting']], - ["Is It a Plane?", False, [], ['Flint and Steel']], - ["Is It a Plane?", False, [], ['Progressive Tools']], - ["Is It a Plane?", False, [], ['Progressive Weapons']], - ["Is It a Plane?", False, [], ['Progressive Armor', 'Shield']], - ["Is It a Plane?", False, [], ['Brewing']], - ["Is It a Plane?", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ["Is It a Plane?", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ["Is It a Plane?", False, [], ['Spyglass']], - ["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']], - ["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']], - ["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', - 'Progressive Weapons', 'Shield', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']], - ["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', 'Brewing', - '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']], - ]) - - def test_42099(self): - self.run_location_tests([ - ["Surge Protector", False, []], - ["Surge Protector", False, [], ['Channeling Book']], - ["Surge Protector", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']], - ["Surge Protector", False, [], ['Enchanting']], - ["Surge Protector", False, [], ['Progressive Tools']], - ["Surge Protector", False, [], ['Progressive Weapons']], - ["Surge Protector", True, ['Progressive Weapons', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Enchanting', 'Progressive Resource Crafting', 'Progressive Resource Crafting', 'Channeling Book']], - ]) - - def test_42100(self): - self.run_location_tests([ - ["Light as a Rabbit", False, []], - ["Light as a Rabbit", False, [], ["Progressive Weapons"]], - ["Light as a Rabbit", False, [], ["Progressive Tools"]], - ["Light as a Rabbit", False, [], ["Progressive Resource Crafting"]], - ["Light as a Rabbit", False, [], ["Bucket"]], - ["Light as a Rabbit", True, ["Progressive Weapons", "Progressive Tools", "Progressive Resource Crafting", "Bucket"]], - ]) - - def test_42101(self): - self.run_location_tests([ - ["Glow and Behold!", False, []], - ["Glow and Behold!", False, [], ["Progressive Weapons"]], - ["Glow and Behold!", False, [], ["Progressive Resource Crafting", "Campfire"]], - ["Glow and Behold!", True, ["Progressive Weapons", "Progressive Resource Crafting"]], - ["Glow and Behold!", True, ["Progressive Weapons", "Campfire"]], - ]) - - def test_42102(self): - self.run_location_tests([ - ["Whatever Floats Your Goat!", False, []], - ["Whatever Floats Your Goat!", False, [], ["Progressive Weapons"]], - ["Whatever Floats Your Goat!", False, [], ["Progressive Resource Crafting", "Campfire"]], - ["Whatever Floats Your Goat!", True, ["Progressive Weapons", "Progressive Resource Crafting"]], - ["Whatever Floats Your Goat!", True, ["Progressive Weapons", "Campfire"]], - ]) - - # bucket, iron pick - def test_42103(self): - self.run_location_tests([ - ["Caves & Cliffs", False, []], - ["Caves & Cliffs", False, [], ["Bucket"]], - ["Caves & Cliffs", False, [], ["Progressive Tools"]], - ["Caves & Cliffs", False, [], ["Progressive Resource Crafting"]], - ["Caves & Cliffs", True, ["Progressive Resource Crafting", "Progressive Tools", "Progressive Tools", "Bucket"]], - ]) - - # bucket, fishing rod, saddle, combat - def test_42104(self): - self.run_location_tests([ - ["Feels like home", False, []], - ["Feels like home", False, [], ['Progressive Resource Crafting']], - ["Feels like home", False, [], ['Progressive Tools']], - ["Feels like home", False, [], ['Progressive Weapons']], - ["Feels like home", False, [], ['Progressive Armor', 'Shield']], - ["Feels like home", False, [], ['Fishing Rod']], - ["Feels like home", False, [], ['Saddle']], - ["Feels like home", False, [], ['Bucket']], - ["Feels like home", False, [], ['Flint and Steel']], - ["Feels like home", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor', 'Fishing Rod']], - ["Feels like home", True, ['Saddle', 'Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield', 'Fishing Rod']], - ]) - - # iron pick, combat - def test_42105(self): - self.run_location_tests([ - ["Sound of Music", False, []], - ["Sound of Music", False, [], ["Progressive Tools"]], - ["Sound of Music", False, [], ["Progressive Resource Crafting"]], - ["Sound of Music", False, [], ["Progressive Weapons"]], - ["Sound of Music", False, [], ["Progressive Armor", "Shield"]], - ["Sound of Music", True, ["Progressive Tools", "Progressive Tools", "Progressive Resource Crafting", "Progressive Weapons", "Progressive Armor"]], - ["Sound of Music", True, ["Progressive Tools", "Progressive Tools", "Progressive Resource Crafting", "Progressive Weapons", "Shield"]], - ]) - - # bucket, nether, villager - def test_42106(self): - self.run_location_tests([ - ["Star Trader", False, []], - ["Star Trader", False, [], ["Bucket"]], - ["Star Trader", False, [], ["Flint and Steel"]], - ["Star Trader", False, [], ["Progressive Tools"]], - ["Star Trader", False, [], ["Progressive Resource Crafting"]], - ["Star Trader", False, [], ["Progressive Weapons"]], - ["Star Trader", True, ["Bucket", "Flint and Steel", "Progressive Tools", "Progressive Resource Crafting", "Progressive Weapons"]], - ]) - - # bucket, redstone -> iron pick, pillager outpost -> adventure - def test_42107(self): - self.run_location_tests([ - ["Birthday Song", False, []], - ["Birthday Song", False, [], ["Bucket"]], - ["Birthday Song", False, [], ["Progressive Tools"]], - ["Birthday Song", False, [], ["Progressive Weapons"]], - ["Birthday Song", False, [], ["Progressive Resource Crafting"]], - ["Birthday Song", True, ["Progressive Resource Crafting", "Progressive Tools", "Progressive Tools", "Progressive Weapons", "Bucket"]], - ]) - - # bucket, adventure - def test_42108(self): - self.run_location_tests([ - ["Bukkit Bukkit", False, []], - ["Bukkit Bukkit", False, [], ["Bucket"]], - ["Bukkit Bukkit", False, [], ["Progressive Tools"]], - ["Bukkit Bukkit", False, [], ["Progressive Weapons"]], - ["Bukkit Bukkit", False, [], ["Progressive Resource Crafting"]], - ["Bukkit Bukkit", True, ["Bucket", "Progressive Tools", "Progressive Weapons", "Progressive Resource Crafting"]], - ]) - - # iron pick, adventure - def test_42109(self): - self.run_location_tests([ - ["It Spreads", False, []], - ["It Spreads", False, [], ["Progressive Tools"]], - ["It Spreads", False, [], ["Progressive Weapons"]], - ["It Spreads", False, [], ["Progressive Resource Crafting"]], - ["It Spreads", True, ["Progressive Tools", "Progressive Tools", "Progressive Weapons", "Progressive Resource Crafting"]], - ]) - - # iron pick, adventure - def test_42110(self): - self.run_location_tests([ - ["Sneak 100", False, []], - ["Sneak 100", False, [], ["Progressive Tools"]], - ["Sneak 100", False, [], ["Progressive Weapons"]], - ["Sneak 100", False, [], ["Progressive Resource Crafting"]], - ["Sneak 100", True, ["Progressive Tools", "Progressive Tools", "Progressive Weapons", "Progressive Resource Crafting"]], - ]) - - # adventure, lead - def test_42111(self): - self.run_location_tests([ - ["When the Squad Hops into Town", False, []], - ["When the Squad Hops into Town", False, [], ["Progressive Weapons"]], - ["When the Squad Hops into Town", False, [], ["Campfire", "Progressive Resource Crafting"]], - ["When the Squad Hops into Town", False, [], ["Lead"]], - ["When the Squad Hops into Town", True, ["Progressive Weapons", "Lead", "Campfire"]], - ["When the Squad Hops into Town", True, ["Progressive Weapons", "Lead", "Progressive Resource Crafting"]], - ]) - - # adventure, lead, nether - def test_42112(self): - self.run_location_tests([ - ["With Our Powers Combined!", False, []], - ["With Our Powers Combined!", False, [], ["Lead"]], - ["With Our Powers Combined!", False, [], ["Bucket", "Progressive Tools"]], - ["With Our Powers Combined!", False, [], ["Flint and Steel"]], - ["With Our Powers Combined!", False, [], ["Progressive Weapons"]], - ["With Our Powers Combined!", False, [], ["Progressive Resource Crafting"]], - ["With Our Powers Combined!", True, ["Lead", "Progressive Weapons", "Progressive Resource Crafting", "Flint and Steel", "Progressive Tools", "Bucket"]], - ["With Our Powers Combined!", True, ["Lead", "Progressive Weapons", "Progressive Resource Crafting", "Flint and Steel", "Progressive Tools", "Progressive Tools", "Progressive Tools"]], - ]) - - # pillager outpost -> adventure - def test_42113(self): - self.run_location_tests([ - ["You've Got a Friend in Me", False, []], - ["You've Got a Friend in Me", False, [], ["Progressive Weapons"]], - ["You've Got a Friend in Me", False, [], ["Campfire", "Progressive Resource Crafting"]], - ["You've Got a Friend in Me", True, ["Progressive Weapons", "Campfire"]], - ["You've Got a Friend in Me", True, ["Progressive Weapons", "Progressive Resource Crafting"]], - ]) diff --git a/worlds/minecraft/test/TestDataLoad.py b/worlds/minecraft/test/TestDataLoad.py deleted file mode 100644 index c14eef071b..0000000000 --- a/worlds/minecraft/test/TestDataLoad.py +++ /dev/null @@ -1,60 +0,0 @@ -import unittest - -from .. import Constants - -class TestDataLoad(unittest.TestCase): - - def test_item_data(self): - item_info = Constants.item_info - - # All items in sub-tables are in all_items - all_items: set = set(item_info['all_items']) - assert set(item_info['progression_items']) <= all_items - assert set(item_info['useful_items']) <= all_items - assert set(item_info['trap_items']) <= all_items - assert set(item_info['required_pool'].keys()) <= all_items - assert set(item_info['junk_weights'].keys()) <= all_items - - # No overlapping ids (because of bee trap stuff) - all_ids: set = set(Constants.item_name_to_id.values()) - assert len(all_items) == len(all_ids) - - def test_location_data(self): - location_info = Constants.location_info - exclusion_info = Constants.exclusion_info - - # Every location has a region and every region's locations are in all_locations - all_locations: set = set(location_info['all_locations']) - all_locs_2: set = set() - for v in location_info['locations_by_region'].values(): - all_locs_2.update(v) - assert all_locations == all_locs_2 - - # All exclusions are locations - for v in exclusion_info.values(): - assert set(v) <= all_locations - - def test_region_data(self): - region_info = Constants.region_info - - # Every entrance and region in mandatory/default/illegal connections is a real entrance and region - all_regions = set() - all_entrances = set() - for v in region_info['regions']: - assert isinstance(v[0], str) - assert isinstance(v[1], list) - all_regions.add(v[0]) - all_entrances.update(v[1]) - - for v in region_info['mandatory_connections']: - assert v[0] in all_entrances - assert v[1] in all_regions - - for v in region_info['default_connections']: - assert v[0] in all_entrances - assert v[1] in all_regions - - for k, v in region_info['illegal_connections'].items(): - assert k in all_regions - assert set(v) <= all_entrances - diff --git a/worlds/minecraft/test/TestEntrances.py b/worlds/minecraft/test/TestEntrances.py deleted file mode 100644 index 946eb23d63..0000000000 --- a/worlds/minecraft/test/TestEntrances.py +++ /dev/null @@ -1,97 +0,0 @@ -from . import MCTestBase - - -class TestEntrances(MCTestBase): - options = { - "shuffle_structures": False, - "structure_compasses": False - } - - def testPortals(self): - self.run_entrance_tests([ - ['Nether Portal', False, []], - ['Nether Portal', False, [], ['Flint and Steel']], - ['Nether Portal', False, [], ['Progressive Resource Crafting']], - ['Nether Portal', False, [], ['Progressive Tools']], - ['Nether Portal', False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ['Nether Portal', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket']], - ['Nether Portal', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools']], - - ['End Portal', False, []], - ['End Portal', False, [], ['Brewing']], - ['End Portal', False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ['End Portal', False, [], ['Flint and Steel']], - ['End Portal', False, [], ['Progressive Resource Crafting']], - ['End Portal', False, [], ['Progressive Tools']], - ['End Portal', False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ['End Portal', False, [], ['Progressive Weapons']], - ['End Portal', False, [], ['Progressive Armor', 'Shield']], - ['End Portal', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ['End Portal', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ['End Portal', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ['End Portal', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ]) - - def testStructures(self): - self.run_entrance_tests([ # Structures 1 and 2 should be logically equivalent - ['Overworld Structure 1', False, []], - ['Overworld Structure 1', False, [], ['Progressive Weapons']], - ['Overworld Structure 1', False, [], ['Progressive Resource Crafting', 'Campfire']], - ['Overworld Structure 1', True, ['Progressive Weapons', 'Progressive Resource Crafting']], - ['Overworld Structure 1', True, ['Progressive Weapons', 'Campfire']], - - ['Overworld Structure 2', False, []], - ['Overworld Structure 2', False, [], ['Progressive Weapons']], - ['Overworld Structure 2', False, [], ['Progressive Resource Crafting', 'Campfire']], - ['Overworld Structure 2', True, ['Progressive Weapons', 'Progressive Resource Crafting']], - ['Overworld Structure 2', True, ['Progressive Weapons', 'Campfire']], - - ['Nether Structure 1', False, []], - ['Nether Structure 1', False, [], ['Flint and Steel']], - ['Nether Structure 1', False, [], ['Progressive Resource Crafting']], - ['Nether Structure 1', False, [], ['Progressive Tools']], - ['Nether Structure 1', False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ['Nether Structure 1', False, [], ['Progressive Weapons']], - ['Nether Structure 1', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', 'Progressive Weapons']], - ['Nether Structure 1', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], - - ['Nether Structure 2', False, []], - ['Nether Structure 2', False, [], ['Flint and Steel']], - ['Nether Structure 2', False, [], ['Progressive Resource Crafting']], - ['Nether Structure 2', False, [], ['Progressive Tools']], - ['Nether Structure 2', False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ['Nether Structure 2', False, [], ['Progressive Weapons']], - ['Nether Structure 2', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', 'Progressive Weapons']], - ['Nether Structure 2', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], - - ['The End Structure', False, []], - ['The End Structure', False, [], ['Brewing']], - ['The End Structure', False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']], - ['The End Structure', False, [], ['Flint and Steel']], - ['The End Structure', False, [], ['Progressive Resource Crafting']], - ['The End Structure', False, [], ['Progressive Tools']], - ['The End Structure', False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], - ['The End Structure', False, [], ['Progressive Weapons']], - ['The End Structure', False, [], ['Progressive Armor', 'Shield']], - ['The End Structure', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ['The End Structure', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ['The End Structure', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Progressive Armor', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - ['The End Structure', True, ['Flint and Steel', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools', - 'Progressive Weapons', 'Shield', - 'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls']], - - ]) \ No newline at end of file diff --git a/worlds/minecraft/test/TestOptions.py b/worlds/minecraft/test/TestOptions.py deleted file mode 100644 index c04a07054c..0000000000 --- a/worlds/minecraft/test/TestOptions.py +++ /dev/null @@ -1,49 +0,0 @@ -from . import MCTestBase -from ..Constants import region_info -from .. import Options - -from BaseClasses import ItemClassification - -class AdvancementTestBase(MCTestBase): - options = { - "advancement_goal": Options.AdvancementGoal.range_end - } - # beatability test implicit - -class ShardTestBase(MCTestBase): - options = { - "egg_shards_required": Options.EggShardsRequired.range_end, - "egg_shards_available": Options.EggShardsAvailable.range_end - } - - # check that itempool is not overfilled with shards - def test_itempool(self): - assert len(self.multiworld.get_unfilled_locations()) == len(self.multiworld.itempool) - -class CompassTestBase(MCTestBase): - def test_compasses_in_pool(self): - structures = [x[1] for x in region_info["default_connections"]] - itempool_str = {item.name for item in self.multiworld.itempool} - for struct in structures: - assert f"Structure Compass ({struct})" in itempool_str - -class NoBeeTestBase(MCTestBase): - options = { - "bee_traps": Options.BeeTraps.range_start - } - - # With no bees, there are no traps in the pool - def test_bees(self): - for item in self.multiworld.itempool: - assert item.classification != ItemClassification.trap - - -class AllBeeTestBase(MCTestBase): - options = { - "bee_traps": Options.BeeTraps.range_end - } - - # With max bees, there are no filler items, only bee traps - def test_bees(self): - for item in self.multiworld.itempool: - assert item.classification != ItemClassification.filler diff --git a/worlds/minecraft/test/__init__.py b/worlds/minecraft/test/__init__.py deleted file mode 100644 index 3d936fe9cb..0000000000 --- a/worlds/minecraft/test/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from test.bases import TestBase, WorldTestBase -from .. import MinecraftWorld, MinecraftOptions - - -class MCTestBase(WorldTestBase, TestBase): - game = "Minecraft" - player: int = 1 - - def _create_items(self, items, player): - singleton = False - if isinstance(items, str): - items = [items] - singleton = True - ret = [self.multiworld.worlds[player].create_item(item) for item in items] - if singleton: - return ret[0] - return ret - - 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] - items.extend(self._create_items(item_pool[0], 1)) - else: - items = self._create_items(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 = self._create_items(new_items, 1) - return self.get_state(items) - diff --git a/worlds/mmbn3/Rom.py b/worlds/mmbn3/Rom.py index 79da50e534..347375c503 100644 --- a/worlds/mmbn3/Rom.py +++ b/worlds/mmbn3/Rom.py @@ -185,7 +185,7 @@ class TextArchive: # As far as I know, this should literally not be possible. # Every script I've looked at has dozens of unused indices, so finding 9 (8 plus one "ending" script) # should be no problem. We re-use these so we don't have to worry about an area getting tons of these - raise AssertionError("Error in generation -- not enough room for progressive undernet in archive "+self.startOffset) + raise AssertionError(f"Error in generation -- not enough room for progressive undernet in archive {self.startOffset} ({hex(self.startOffset)})") for i in range(9): # There are 8 progressive undernet ranks new_script_index = self.unused_indices[i] new_script = ArchiveScript(new_script_index, generate_progressive_undernet(i, self.unused_indices[i+1])) @@ -319,15 +319,16 @@ class MMBN3DeltaPatch(APDeltaPatch): def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() if not file_name: - bn3_options = options.get("mmbn3_options", None) + from worlds.mmbn3 import MMBN3World + bn3_options = MMBN3World.settings + if bn3_options is None: file_name = "Mega Man Battle Network 3 - Blue Version (USA).gba" else: file_name = bn3_options["rom_file"] if not os.path.exists(file_name): - file_name = Utils.local_path(file_name) + file_name = Utils.user_path(file_name) return file_name diff --git a/worlds/mmbn3/__init__.py b/worlds/mmbn3/__init__.py index 507ddbc21f..80716977d3 100644 --- a/worlds/mmbn3/__init__.py +++ b/worlds/mmbn3/__init__.py @@ -1,7 +1,6 @@ import os import settings import typing -import threading from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification, Region, Entrance, \ LocationProgressType @@ -16,7 +15,7 @@ from .Options import MMBN3Options from .Regions import regions, RegionName from .Names.ItemName import ItemName from .Names.LocationName import LocationName -from worlds.generic.Rules import add_item_rule, add_rule +from worlds.generic.Rules import add_item_rule, add_rule, forbid_item class MMBN3Settings(settings.Group): @@ -26,8 +25,15 @@ class MMBN3Settings(settings.Group): description = "MMBN3 ROM File" md5s = [MMBN3DeltaPatch.hash] + class RomStart(str): + """ + Set this to false to never autostart a rom (such as after patching), + true for operating system default program + Alternatively, a path to a program to open the .gba file with + """ + rom_file: RomFile = RomFile(RomFile.copy_to) - rom_start: bool = True + rom_start: RomStart | bool = True class MMBN3Web(WebWorld): @@ -203,134 +209,134 @@ class MMBN3World(World): # Set WWW ID requirements def has_www_id(state): return state.has(ItemName.WWW_ID, self.player) - add_rule(self.multiworld.get_location(LocationName.ACDC_1_PMD, self.player), has_www_id) - add_rule(self.multiworld.get_location(LocationName.SciLab_1_WWW_BMD, self.player), has_www_id) - add_rule(self.multiworld.get_location(LocationName.Yoka_1_WWW_BMD, self.player), has_www_id) - add_rule(self.multiworld.get_location(LocationName.Undernet_1_WWW_BMD, self.player), has_www_id) + add_rule(self.get_location(LocationName.ACDC_1_PMD), has_www_id) + add_rule(self.get_location(LocationName.SciLab_1_WWW_BMD), has_www_id) + add_rule(self.get_location(LocationName.Yoka_1_WWW_BMD), has_www_id) + add_rule(self.get_location(LocationName.Undernet_1_WWW_BMD), has_www_id) # Set Press Program requirements def has_press(state): return state.has(ItemName.Press, self.player) - add_rule(self.multiworld.get_location(LocationName.Yoka_1_PMD, self.player), has_press) - add_rule(self.multiworld.get_location(LocationName.Yoka_2_Upper_BMD, self.player), has_press) - add_rule(self.multiworld.get_location(LocationName.Beach_2_East_BMD, self.player), has_press) - add_rule(self.multiworld.get_location(LocationName.Hades_South_BMD, self.player), has_press) - add_rule(self.multiworld.get_location(LocationName.Secret_3_BugFrag_BMD, self.player), has_press) - add_rule(self.multiworld.get_location(LocationName.Secret_3_Island_BMD, self.player), has_press) + add_rule(self.get_location(LocationName.Yoka_1_PMD), has_press) + add_rule(self.get_location(LocationName.Yoka_2_Upper_BMD), has_press) + add_rule(self.get_location(LocationName.Beach_2_East_BMD), has_press) + add_rule(self.get_location(LocationName.Hades_South_BMD), has_press) + add_rule(self.get_location(LocationName.Secret_3_BugFrag_BMD), has_press) + add_rule(self.get_location(LocationName.Secret_3_Island_BMD), has_press) # Set Purple Mystery Data Unlocker access def can_unlock(state): return state.can_reach_region(RegionName.SciLab_Overworld, self.player) or \ state.can_reach_region(RegionName.SciLab_Cyberworld, self.player) or \ state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) or \ state.has(ItemName.Unlocker, self.player, 8) # There are 8 PMDs that aren't in one of the above areas - add_rule(self.multiworld.get_location(LocationName.ACDC_1_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Yoka_1_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Beach_1_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Undernet_7_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Mayls_HP_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.SciLab_Dads_Computer_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Zoo_Panda_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Beach_DNN_Security_Panel_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Beach_DNN_Main_Console_PMD, self.player), can_unlock) - add_rule(self.multiworld.get_location(LocationName.Tamakos_HP_PMD, self.player), can_unlock) + add_rule(self.get_location(LocationName.ACDC_1_PMD), can_unlock) + add_rule(self.get_location(LocationName.Yoka_1_PMD), can_unlock) + add_rule(self.get_location(LocationName.Beach_1_PMD), can_unlock) + add_rule(self.get_location(LocationName.Undernet_7_PMD), can_unlock) + add_rule(self.get_location(LocationName.Mayls_HP_PMD), can_unlock) + add_rule(self.get_location(LocationName.SciLab_Dads_Computer_PMD), can_unlock) + add_rule(self.get_location(LocationName.Zoo_Panda_PMD), can_unlock) + add_rule(self.get_location(LocationName.Beach_DNN_Security_Panel_PMD), can_unlock) + add_rule(self.get_location(LocationName.Beach_DNN_Main_Console_PMD), can_unlock) + add_rule(self.get_location(LocationName.Tamakos_HP_PMD), can_unlock) # Set Job additional area access - self.multiworld.get_location(LocationName.Please_deliver_this, self.player).access_rule = \ + self.get_location(LocationName.Please_deliver_this).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \ state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) - self.multiworld.get_location(LocationName.My_Navi_is_sick, self.player).access_rule =\ + self.get_location(LocationName.My_Navi_is_sick).access_rule =\ lambda state: \ state.has(ItemName.Recov30_star, self.player) - self.multiworld.get_location(LocationName.Help_me_with_my_son, self.player).access_rule =\ + self.get_location(LocationName.Help_me_with_my_son).access_rule =\ lambda state:\ state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \ state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Transmission_error, self.player).access_rule = \ + self.get_location(LocationName.Transmission_error).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) - self.multiworld.get_location(LocationName.Chip_Prices, self.player).access_rule = \ + self.get_location(LocationName.Chip_Prices).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) and \ state.can_reach_region(RegionName.SciLab_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Im_broke, self.player).access_rule = \ + self.get_location(LocationName.Im_broke).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \ state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Rare_chips_for_cheap, self.player).access_rule = \ + self.get_location(LocationName.Rare_chips_for_cheap).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) - self.multiworld.get_location(LocationName.Be_my_boyfriend, self.player).access_rule =\ + self.get_location(LocationName.Be_my_boyfriend).access_rule =\ lambda state: \ state.can_reach_region(RegionName.Beach_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Will_you_deliver, self.player).access_rule=\ + self.get_location(LocationName.Will_you_deliver).access_rule=\ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \ state.can_reach_region(RegionName.Beach_Overworld, self.player) and \ state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Somebody_please_help, self.player).access_rule = \ + self.get_location(LocationName.Somebody_please_help).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) - self.multiworld.get_location(LocationName.Looking_for_condor, self.player).access_rule = \ + self.get_location(LocationName.Looking_for_condor).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \ state.can_reach_region(RegionName.Beach_Overworld, self.player) and \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) - self.multiworld.get_location(LocationName.Help_with_rehab, self.player).access_rule = \ + self.get_location(LocationName.Help_with_rehab).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Beach_Overworld, self.player) - self.multiworld.get_location(LocationName.Help_with_rehab_bonus, self.player).access_rule = \ + self.get_location(LocationName.Help_with_rehab_bonus).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Beach_Overworld, self.player) - self.multiworld.get_location(LocationName.Old_Master, self.player).access_rule = \ + self.get_location(LocationName.Old_Master).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \ state.can_reach_region(RegionName.Beach_Overworld, self.player) - self.multiworld.get_location(LocationName.Catching_gang_members, self.player).access_rule = \ + self.get_location(LocationName.Catching_gang_members).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) and \ state.has(ItemName.Press, self.player) - self.multiworld.get_location(LocationName.Please_adopt_a_virus, self.player).access_rule = \ + self.get_location(LocationName.Please_adopt_a_virus).access_rule = \ lambda state: \ state.can_reach_region(RegionName.SciLab_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Legendary_Tomes, self.player).access_rule = \ + self.get_location(LocationName.Legendary_Tomes).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Beach_Overworld, self.player) and \ state.can_reach_region(RegionName.Undernet, self.player) and \ state.can_reach_region(RegionName.Deep_Undernet, self.player) and \ state.has_all({ItemName.Press, ItemName.Magnum1_A}, self.player) - self.multiworld.get_location(LocationName.Legendary_Tomes_Treasure, self.player).access_rule = \ + self.get_location(LocationName.Legendary_Tomes_Treasure).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \ state.can_reach_location(LocationName.Legendary_Tomes, self.player) - self.multiworld.get_location(LocationName.Hide_and_seek_First_Child, self.player).access_rule = \ + self.get_location(LocationName.Hide_and_seek_First_Child).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) - self.multiworld.get_location(LocationName.Hide_and_seek_Second_Child, self.player).access_rule = \ + self.get_location(LocationName.Hide_and_seek_Second_Child).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) - self.multiworld.get_location(LocationName.Hide_and_seek_Third_Child, self.player).access_rule = \ + self.get_location(LocationName.Hide_and_seek_Third_Child).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) - self.multiworld.get_location(LocationName.Hide_and_seek_Fourth_Child, self.player).access_rule = \ + self.get_location(LocationName.Hide_and_seek_Fourth_Child).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) - self.multiworld.get_location(LocationName.Hide_and_seek_Completion, self.player).access_rule = \ + self.get_location(LocationName.Hide_and_seek_Completion).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Yoka_Overworld, self.player) - self.multiworld.get_location(LocationName.Finding_the_blue_Navi, self.player).access_rule = \ + self.get_location(LocationName.Finding_the_blue_Navi).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Undernet, self.player) - self.multiworld.get_location(LocationName.Give_your_support, self.player).access_rule = \ + self.get_location(LocationName.Give_your_support).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Beach_Overworld, self.player) - self.multiworld.get_location(LocationName.Stamp_collecting, self.player).access_rule = \ + self.get_location(LocationName.Stamp_collecting).access_rule = \ lambda state: \ state.can_reach_region(RegionName.Beach_Overworld, self.player) and \ state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) and \ state.can_reach_region(RegionName.SciLab_Cyberworld, self.player) and \ state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) and \ state.can_reach_region(RegionName.Beach_Cyberworld, self.player) - self.multiworld.get_location(LocationName.Help_with_a_will, self.player).access_rule = \ + self.get_location(LocationName.Help_with_a_will).access_rule = \ lambda state: \ state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \ state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) and \ @@ -340,100 +346,115 @@ class MMBN3World(World): state.can_reach_region(RegionName.Undernet, self.player) # Set Trade quests - self.multiworld.get_location(LocationName.ACDC_SonicWav_W_Trade, self.player).access_rule =\ + self.get_location(LocationName.ACDC_SonicWav_W_Trade).access_rule =\ lambda state: state.has(ItemName.SonicWav_W, self.player) - self.multiworld.get_location(LocationName.ACDC_Bubbler_C_Trade, self.player).access_rule =\ + self.get_location(LocationName.ACDC_Bubbler_C_Trade).access_rule =\ lambda state: state.has(ItemName.Bubbler_C, self.player) - self.multiworld.get_location(LocationName.ACDC_Recov120_S_Trade, self.player).access_rule =\ + self.get_location(LocationName.ACDC_Recov120_S_Trade).access_rule =\ lambda state: state.has(ItemName.Recov120_S, self.player) - self.multiworld.get_location(LocationName.SciLab_Shake1_S_Trade, self.player).access_rule =\ + self.get_location(LocationName.SciLab_Shake1_S_Trade).access_rule =\ lambda state: state.has(ItemName.Shake1_S, self.player) - self.multiworld.get_location(LocationName.Yoka_FireSwrd_P_Trade, self.player).access_rule =\ + self.get_location(LocationName.Yoka_FireSwrd_P_Trade).access_rule =\ lambda state: state.has(ItemName.FireSwrd_P, self.player) - self.multiworld.get_location(LocationName.Hospital_DynaWav_V_Trade, self.player).access_rule =\ + self.get_location(LocationName.Hospital_DynaWav_V_Trade).access_rule =\ lambda state: state.has(ItemName.DynaWave_V, self.player) - self.multiworld.get_location(LocationName.Beach_DNN_WideSwrd_C_Trade, self.player).access_rule =\ + self.get_location(LocationName.Beach_DNN_WideSwrd_C_Trade).access_rule =\ lambda state: state.has(ItemName.WideSwrd_C, self.player) - self.multiworld.get_location(LocationName.Beach_DNN_HoleMetr_H_Trade, self.player).access_rule =\ + self.get_location(LocationName.Beach_DNN_HoleMetr_H_Trade).access_rule =\ lambda state: state.has(ItemName.HoleMetr_H, self.player) - self.multiworld.get_location(LocationName.Beach_DNN_Shadow_J_Trade, self.player).access_rule =\ + self.get_location(LocationName.Beach_DNN_Shadow_J_Trade).access_rule =\ lambda state: state.has(ItemName.Shadow_J, self.player) - self.multiworld.get_location(LocationName.Hades_GrabBack_K_Trade, self.player).access_rule =\ + self.get_location(LocationName.Hades_GrabBack_K_Trade).access_rule =\ lambda state: state.has(ItemName.GrabBack_K, self.player) # Set Number Traders # The first 8 are considered cheap enough to grind for in ACDC. Protip: Try grinding in the tank - self.multiworld.get_location(LocationName.Numberman_Code_09, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_09).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_10, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_10).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_11, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_11).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_12, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_12).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_13, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_13).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_14, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_14).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_15, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_15).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_16, self.player).access_rule = \ + self.get_location(LocationName.Numberman_Code_16).access_rule = \ lambda state: self.explore_score(state) > 2 - self.multiworld.get_location(LocationName.Numberman_Code_17, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_17).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_18, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_18).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_19, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_19).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_20, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_20).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_21, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_21).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_22, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_22).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_23, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_23).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_24, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_24).access_rule =\ lambda state: self.explore_score(state) > 4 - self.multiworld.get_location(LocationName.Numberman_Code_25, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_25).access_rule =\ lambda state: self.explore_score(state) > 8 - self.multiworld.get_location(LocationName.Numberman_Code_26, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_26).access_rule =\ lambda state: self.explore_score(state) > 8 - self.multiworld.get_location(LocationName.Numberman_Code_27, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_27).access_rule =\ lambda state: self.explore_score(state) > 8 - self.multiworld.get_location(LocationName.Numberman_Code_28, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_28).access_rule =\ lambda state: self.explore_score(state) > 8 - self.multiworld.get_location(LocationName.Numberman_Code_29, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_29).access_rule =\ lambda state: self.explore_score(state) > 10 - self.multiworld.get_location(LocationName.Numberman_Code_30, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_30).access_rule =\ lambda state: self.explore_score(state) > 10 - self.multiworld.get_location(LocationName.Numberman_Code_31, self.player).access_rule =\ + self.get_location(LocationName.Numberman_Code_31).access_rule =\ lambda state: self.explore_score(state) > 10 #miscellaneous locations with extra requirements - add_rule(self.multiworld.get_location(LocationName.Comedian, self.player), + add_rule(self.get_location(LocationName.Comedian), lambda state: state.has(ItemName.Humor, self.player)) - add_rule(self.multiworld.get_location(LocationName.Villain, self.player), + add_rule(self.get_location(LocationName.Villain), lambda state: state.has(ItemName.BlckMnd, self.player)) - def not_undernet(item): return item.code != item_table[ItemName.Progressive_Undernet_Rank].code or item.player != self.player - self.multiworld.get_location(LocationName.WWW_1_Central_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_1_East_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_2_East_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_2_Northwest_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_3_East_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_3_North_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_4_Northwest_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_4_Central_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_Wall_BMD, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_Control_Room_1_Screen, self.player).item_rule = not_undernet - self.multiworld.get_location(LocationName.WWW_Wilys_Desk, self.player).item_rule = not_undernet + forbid_item(self.get_location(LocationName.WWW_1_Central_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_1_East_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_2_East_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_2_Northwest_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_3_East_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_3_North_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_4_Northwest_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_4_Central_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_Wall_BMD), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_Control_Room_1_Screen), + ItemName.Progressive_Undernet_Rank, self.player) + forbid_item(self.get_location(LocationName.WWW_Wilys_Desk), + ItemName.Progressive_Undernet_Rank, self.player) + + # I have no fuckin clue why this specific location shits the bed on a progressive undernet rank. + # If you ever figure it out I will buy you a pizza. + forbid_item(self.get_location(LocationName.Chocolate_Shop_07), + ItemName.Progressive_Undernet_Rank, self.player) # place "Victory" at "Final Boss" and set collection as win condition - self.multiworld.get_location(LocationName.Alpha_Defeated, self.player) \ + self.get_location(LocationName.Alpha_Defeated) \ .place_locked_item(self.create_event(ItemName.Victory)) self.multiworld.completion_condition[self.player] = \ lambda state: state.has(ItemName.Victory, self.player) diff --git a/worlds/mmbn3/data/bn3-ap-patch.bsdiff b/worlds/mmbn3/data/bn3-ap-patch.bsdiff index d55fecad80..43ace91bcd 100644 Binary files a/worlds/mmbn3/data/bn3-ap-patch.bsdiff and b/worlds/mmbn3/data/bn3-ap-patch.bsdiff differ diff --git a/worlds/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py index f2bcf1220f..32849eec9b 100644 --- a/worlds/musedash/MuseDashData.py +++ b/worlds/musedash/MuseDashData.py @@ -641,4 +641,24 @@ SONG_DATA: Dict[str, SongData] = { "Save Yourself": SongData(2900765, "85-3", "Happy Otaku Pack Vol.20", True, 5, 7, 10), "Menace": SongData(2900766, "85-4", "Happy Otaku Pack Vol.20", True, 7, 9, 11), "Dangling": SongData(2900767, "85-5", "Happy Otaku Pack Vol.20", True, 6, 8, 10), + "Inverted World": SongData(2900768, "86-0", "Aquaria Cruising Guide", True, 4, 6, 8), + "Suito": SongData(2900769, "86-1", "Aquaria Cruising Guide", True, 6, 8, 11), + "The Promised Land": SongData(2900770, "86-2", "Aquaria Cruising Guide", True, 4, 6, 9), + "Alfheim's faith": SongData(2900771, "86-3", "Aquaria Cruising Guide", True, 6, 8, 11), + "Heaven's Cage": SongData(2900772, "86-4", "Aquaria Cruising Guide", True, 5, 7, 10), + "Broomstick adventure!": SongData(2900773, "86-5", "Aquaria Cruising Guide", True, 7, 9, 11), + "Strong Nurse Buro-chan!": SongData(2900774, "43-61", "MD Plus Project", True, 5, 7, 9), + "Cubism": SongData(2900775, "43-62", "MD Plus Project", False, 5, 7, 9), + "Cubibibibism": SongData(2900776, "43-63", "MD Plus Project", False, 6, 8, 10), + "LET'S TOAST!!": SongData(2900777, "43-64", "MD Plus Project", False, 6, 8, 10), + "#YamiKawa": SongData(2900778, "43-65", "MD Plus Project", False, 5, 7, 10), + "Rainy Step": SongData(2900779, "43-66", "MD Plus Project", False, 2, 5, 8), + "OHOSHIKATSU": SongData(2900780, "43-67", "MD Plus Project", False, 5, 7, 10), + "Dreamy Day": SongData(2900781, "87-0", "Aim to Be a Rhythm Master!", False, 2, 5, 7), + "Futropolis": SongData(2900782, "87-1", "Aim to Be a Rhythm Master!", False, 4, 7, 9), + "Quo Vadis": SongData(2900783, "87-2", "Aim to Be a Rhythm Master!", False, 5, 7, 10), + "REANIMATE": SongData(2900784, "87-3", "Aim to Be a Rhythm Master!", False, 5, 7, 10), + "Ineffabilis": SongData(2900785, "87-4", "Aim to Be a Rhythm Master!", False, 3, 7, 10), + "DaJiaHao": SongData(2900786, "87-5", "Aim to Be a Rhythm Master!", False, 5, 7, 10), + "Echoes of SeraphiM": SongData(2900787, "87-6", "Aim to Be a Rhythm Master!", False, 5, 8, 10), } diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py index 9f729c2d03..83e58274f0 100644 --- a/worlds/musedash/Options.py +++ b/worlds/musedash/Options.py @@ -175,6 +175,13 @@ class ExcludeSongs(SongSet): """ display_name = "Exclude Songs" +class GoalSong(SongSet): + """ + One of the selected songs will be guaranteed to show up as the final Goal Song. + - You must have the DLC enabled to play these songs. + - If no songs are chosen, then the song will be randomly chosen from the available songs. + """ + display_name = "Goal Song" md_option_groups = [ OptionGroup("Song Choice", [ @@ -182,6 +189,7 @@ md_option_groups = [ StreamerModeEnabled, IncludeSongs, ExcludeSongs, + GoalSong, ]), OptionGroup("Difficulty", [ GradeNeeded, @@ -214,6 +222,7 @@ class MuseDashOptions(PerGameCommonOptions): death_link: DeathLink include_songs: IncludeSongs exclude_songs: ExcludeSongs + goal_song: GoalSong # Removed allow_just_as_planned_dlc_songs: Removed diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py index d793308a7c..eb82148c1b 100644 --- a/worlds/musedash/__init__.py +++ b/worlds/musedash/__init__.py @@ -119,12 +119,24 @@ class MuseDashWorld(World): start_items = self.options.start_inventory.value.keys() include_songs = self.options.include_songs.value exclude_songs = self.options.exclude_songs.value + chosen_goal_songs = sorted(self.options.goal_song) self.starting_songs = [s for s in start_items if s in song_items] self.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs) self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs] self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs) + # Making sure songs chosen for goal are allowed by DLC and remove the chosen from being added to the pool. + if chosen_goal_songs: + chosen_goal_songs = self.md_collection.filter_songs_to_dlc(chosen_goal_songs, dlc_songs) + if chosen_goal_songs: + self.random.shuffle(chosen_goal_songs) + self.victory_song_name = chosen_goal_songs.pop() + if self.victory_song_name in self.starting_songs: + self.starting_songs.remove(self.victory_song_name) + if self.victory_song_name in self.included_songs: + self.included_songs.remove(self.victory_song_name) + return [s for s in available_song_keys if s not in start_items and s not in include_songs and s not in exclude_songs] @@ -139,12 +151,13 @@ class MuseDashWorld(World): if included_song_count > additional_song_count: # If so, we want to thin the list, thus let's get the goal song and starter songs while we are at it. self.random.shuffle(self.included_songs) - self.victory_song_name = self.included_songs.pop() + if not self.victory_song_name: + self.victory_song_name = self.included_songs.pop() while len(self.included_songs) > additional_song_count: next_song = self.included_songs.pop() if len(self.starting_songs) < starting_song_count: self.starting_songs.append(next_song) - else: + elif not self.victory_song_name: # If not, choose a random victory song from the available songs chosen_song = self.random.randrange(0, len(available_song_keys) + included_song_count) if chosen_song < included_song_count: @@ -153,6 +166,8 @@ class MuseDashWorld(World): else: self.victory_song_name = available_song_keys[chosen_song - included_song_count] del available_song_keys[chosen_song - included_song_count] + elif self.victory_song_name in available_song_keys: + available_song_keys.remove(self.victory_song_name) # Next, make sure the starting songs are fulfilled if len(self.starting_songs) < starting_song_count: @@ -173,7 +188,7 @@ class MuseDashWorld(World): def create_item(self, name: str) -> Item: if name == self.md_collection.MUSIC_SHEET_NAME: - return MuseDashFixedItem(name, ItemClassification.progression_skip_balancing, + return MuseDashFixedItem(name, ItemClassification.progression_deprioritized_skip_balancing, self.md_collection.MUSIC_SHEET_CODE, self.player) filler = self.md_collection.filler_items.get(name) diff --git a/worlds/noita/options.py b/worlds/noita/options.py index 8a973a0d72..6798cc8ccd 100644 --- a/worlds/noita/options.py +++ b/worlds/noita/options.py @@ -121,10 +121,8 @@ class ShopPrice(Choice): class NoitaDeathLink(DeathLink): - """ - When you die, everyone dies. Of course, the reverse is true too. - You can disable this in the in-game mod options. - """ + __doc__ = (DeathLink.__doc__ + "\n\n You can disable this or set it to give yourself a trap effect when " + + "another player dies in the in-game mod options.") @dataclass diff --git a/worlds/oot/Patches.py b/worlds/oot/Patches.py index cd940e052a..db7be3d4dd 100644 --- a/worlds/oot/Patches.py +++ b/worlds/oot/Patches.py @@ -38,6 +38,7 @@ AP_JUNK = 0xD5 class OoTContainer(APPatch): game: str = 'Ocarina of Time' + patch_file_ending = ".apz5" def __init__(self, patch_data: bytes, base_path: str, output_directory: str, player = None, player_name: str = "", server: str = ""): diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index ed025f4971..d9465f1761 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -1324,10 +1324,20 @@ class OOTWorld(World): state.prog_items[self.player][alt_item_name] -= count if state.prog_items[self.player][alt_item_name] < 1: del (state.prog_items[self.player][alt_item_name]) + # invalidate caches, nothing can be trusted anymore now + state.child_reachable_regions[self.player] = set() + state.child_blocked_connections[self.player] = set() + state.adult_reachable_regions[self.player] = set() + state.adult_blocked_connections[self.player] = set() state._oot_stale[self.player] = True return True changed = super().remove(state, item) if changed: + # invalidate caches, nothing can be trusted anymore now + state.child_reachable_regions[self.player] = set() + state.child_blocked_connections[self.player] = set() + state.adult_reachable_regions[self.player] = set() + state.adult_blocked_connections[self.player] = set() state._oot_stale[self.player] = True return changed diff --git a/worlds/oot/docs/setup_de.md b/worlds/oot/docs/setup_de.md index 92c3150a7d..c35dd2769f 100644 --- a/worlds/oot/docs/setup_de.md +++ b/worlds/oot/docs/setup_de.md @@ -7,13 +7,13 @@ Da wir BizHawk benutzen, gilt diese Anleitung nur für Windows und Linux. ## Benötigte Software - BizHawk: [BizHawk Veröffentlichungen von TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) - - Version 2.3.1 und später werden unterstützt. Version 2.9 ist empfohlen. + - Version 2.3.1 und später werden unterstützt. Version 2.10 ist empfohlen. - Detailierte Installtionsanweisungen für BizHawk können über den obrigen Link gefunden werden. - Windows-Benutzer müssen die Prerequisiten installiert haben. Diese können ebenfalls über den obrigen Link gefunden werden. - Der integrierte Archipelago-Client, welcher [hier](https://github.com/ArchipelagoMW/Archipelago/releases) installiert werden kann. -- Eine `Ocarina of Time v1.0 US(?) ROM`. (Nicht aus Europa und keine Master-Quest oder Debug-Rom!) +- Eine `Ocarina of Time v1.0 US ROM`. (Nicht aus Europa und keine Master-Quest oder Debug-Rom!) ## Konfigurieren von BizHawk diff --git a/worlds/oot/docs/setup_en.md b/worlds/oot/docs/setup_en.md index 553f1820c3..31b7137bd8 100644 --- a/worlds/oot/docs/setup_en.md +++ b/worlds/oot/docs/setup_en.md @@ -7,11 +7,11 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst ## Required Software - BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) - - Version 2.3.1 and later are supported. Version 2.7 is recommended for stability. + - Version 2.3.1 and later are supported. Version 2.10 is recommended for stability. - Detailed installation instructions for BizHawk can be found at the above link. - Windows users must run the prereq installer first, which can also be found at the above link. - The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases). -- An Ocarina of Time v1.0 ROM. +- A US Ocarina of Time v1.0 ROM. ## Configuring BizHawk diff --git a/worlds/oot/docs/setup_fr.md b/worlds/oot/docs/setup_fr.md index 40b0e8f571..eb2e97384a 100644 --- a/worlds/oot/docs/setup_fr.md +++ b/worlds/oot/docs/setup_fr.md @@ -7,12 +7,12 @@ Comme nous utilisons BizHawk, ce guide s'applique uniquement aux systèmes Windo ## Logiciel requis - BizHawk : [Sorties BizHawk de TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) - - Les versions 2.3.1 et ultérieures sont prises en charge. La version 2.7 est recommandée pour des raisons de stabilité. + - Les versions 2.3.1 et ultérieures sont prises en charge. La version 2.10 est recommandée pour des raisons de stabilité. - Des instructions d'installation détaillées pour BizHawk peuvent être trouvées sur le lien ci-dessus. - Les utilisateurs Windows doivent d'abord exécuter le programme d'installation des prérequis, qui peut également être trouvé sur le lien ci-dessus. - Le client Archipelago intégré, qui peut être installé [ici](https://github.com/ArchipelagoMW/Archipelago/releases) (sélectionnez « Ocarina of Time Client » lors de l'installation). -- Une ROM Ocarina of Time v1.0. +- Un fichier ROM v1.0 US d'Ocarina of Time. ## Configuration de BizHawk diff --git a/worlds/paint/__init__.py b/worlds/paint/__init__.py new file mode 100644 index 0000000000..8e501ff3dc --- /dev/null +++ b/worlds/paint/__init__.py @@ -0,0 +1,128 @@ +from typing import Dict, Any + +from BaseClasses import CollectionState, Item, MultiWorld, Tutorial, Region +from Options import OptionError +from worlds.AutoWorld import LogicMixin, World, WebWorld +from .items import item_table, PaintItem, item_data_table, traps, deathlink_traps +from .locations import location_table, PaintLocation, location_data_table +from .options import PaintOptions + + +class PaintWebWorld(WebWorld): + theme = "partyTime" + + setup_en = Tutorial( + tutorial_name="Start Guide", + description="A guide to playing Paint in Archipelago.", + language="English", + file_name="guide_en.md", + link="guide/en", + authors=["MarioManTAW"] + ) + + tutorials = [setup_en] + + +class PaintWorld(World): + """ + The classic Microsoft app, reimagined as an Archipelago game! Find your tools, expand your canvas, and paint the + greatest image the world has ever seen. + """ + game = "Paint" + options_dataclass = PaintOptions + options: PaintOptions + web = PaintWebWorld() + location_name_to_id = location_table + item_name_to_id = item_table + origin_region_name = "Canvas" + + def generate_early(self) -> None: + if self.options.canvas_size_increment < 50 and self.options.logic_percent <= 55: + if self.multiworld.players == 1: + raise OptionError("Logic Percent must be greater than 55 when generating a single-player world with " + "Canvas Size Increment below 50.") + + def get_filler_item_name(self) -> str: + if self.random.randint(0, 99) >= self.options.trap_count: + return "Additional Palette Color" + elif self.options.death_link: + return self.random.choice(deathlink_traps) + else: + return self.random.choice(traps) + + def create_item(self, name: str) -> PaintItem: + item = PaintItem(name, item_data_table[name].type, item_data_table[name].code, self.player) + return item + + def create_items(self) -> None: + starting_tools = ["Brush", "Pencil", "Eraser/Color Eraser", "Airbrush", "Line", "Rectangle", "Ellipse", + "Rounded Rectangle"] + self.push_precollected(self.create_item("Magnifier")) + self.push_precollected(self.create_item(starting_tools.pop(self.options.starting_tool))) + items_to_create = ["Free-Form Select", "Select", "Fill With Color", "Pick Color", "Text", "Curve", "Polygon"] + items_to_create += starting_tools + items_to_create += ["Progressive Canvas Width"] * (400 // self.options.canvas_size_increment) + items_to_create += ["Progressive Canvas Height"] * (300 // self.options.canvas_size_increment) + depth_items = ["Progressive Color Depth (Red)", "Progressive Color Depth (Green)", + "Progressive Color Depth (Blue)"] + for item in depth_items: + self.push_precollected(self.create_item(item)) + items_to_create += depth_items * 6 + pre_filled = len(items_to_create) + to_fill = len(self.get_region("Canvas").locations) + if pre_filled > to_fill: + raise OptionError(f"{self.player_name}'s Paint world has too few locations for its required items. " + "Consider adding more locations by raising logic percent or adding fractional checks. " + "Alternatively, increasing the canvas size increment will require fewer items.") + while len(items_to_create) < (to_fill - pre_filled) * (self.options.trap_count / 100) + pre_filled: + if self.options.death_link: + items_to_create += [self.random.choice(deathlink_traps)] + else: + items_to_create += [self.random.choice(traps)] + while len(items_to_create) < to_fill: + items_to_create += ["Additional Palette Color"] + self.multiworld.itempool += [self.create_item(item) for item in items_to_create] + + def create_regions(self) -> None: + canvas = Region("Canvas", self.player, self.multiworld) + canvas.locations += [PaintLocation(self.player, loc_name, loc_data.address, canvas) + for loc_name, loc_data in location_data_table.items() + if location_exists_with_options(self, loc_data.address)] + + self.multiworld.regions += [canvas] + + def set_rules(self) -> None: + from .rules import set_completion_rules + set_completion_rules(self, self.player) + + def fill_slot_data(self) -> Dict[str, Any]: + return dict(self.options.as_dict("logic_percent", "goal_percent", "goal_image", "death_link", + "canvas_size_increment"), version="0.5.2") + + def collect(self, state: CollectionState, item: Item) -> bool: + change = super().collect(state, item) + if change: + state.paint_percent_stale[self.player] = True + return change + + def remove(self, state: CollectionState, item: Item) -> bool: + change = super().remove(state, item) + if change: + state.paint_percent_stale[self.player] = True + return change + + +def location_exists_with_options(world: PaintWorld, location: int): + l = location % 198600 + return l <= world.options.logic_percent * 4 and (l % 4 == 0 or + (l > world.options.half_percent_checks * 4 and l % 2 == 0) or + l > world.options.quarter_percent_checks * 4) + + +class PaintState(LogicMixin): + paint_percent_available: dict[int, float] # per player + paint_percent_stale: dict[int, bool] + + def init_mixin(self, multiworld: MultiWorld) -> None: + self.paint_percent_available = {player: 0 for player in multiworld.get_game_players("Paint")} + self.paint_percent_stale = {player: True for player in multiworld.get_game_players("Paint")} diff --git a/worlds/paint/docs/en_Paint.md b/worlds/paint/docs/en_Paint.md new file mode 100644 index 0000000000..845c726848 --- /dev/null +++ b/worlds/paint/docs/en_Paint.md @@ -0,0 +1,35 @@ +# Paint + +## Where is the options page? + +You can read through all the options and generate a YAML [here](../player-options). + +## What does randomization do to this game? + +Most tools are locked from the start, leaving only the Magnifier and one drawing tool, specified in the game options. +Canvas size is locked and will only expand when the Progressive Canvas Width and Progressive Canvas Height items are +obtained. Additionally, color selection is limited, starting with only a few possible colors but gaining more options +when Progressive Color Depth items are obtained in each of the red, green, and blue components. + +Location checks are sent out based on similarity to a target image, measured as a percentage. Every percentage point up +to a maximum set in the game options will send a new check, and the game will be considered done when a certain target +percentage (also set in the game options) is reached. + +## What other changes are made to the game? + +This project is based on [JS Paint](https://jspaint.app), an open-source remake of Microsoft Paint. Most features will +work similarly to this version but some features have also been removed. Most notably, pasting functionality has been +completely removed to prevent cheating. + +With the addition of a second canvas to display the target image, there are some additional features that may not be +intuitive. There are two special functions in the Extras menu to help visualize how to improve your score. Similarity +Mode (shortcut Ctrl+Shift+M) shows the similarity of each portion of the image in grayscale, with white representing +perfect similarity and black representing no similarity. Conversely, Difference Mode (shortcut Ctrl+M) visualizes the +differences between what has been drawn and the target image in full color, showing the direction both hue and +lightness need to shift to match the target. Additionally, once unlocked, the Pick Color tool can be used on both the +main and target canvases. + +Custom colors have been streamlined for Archipelago play. The only starting palette options are black and white, but +additional palette slots can be unlocked as Archipelago items. Double-clicking on any palette slot will allow you to +edit the color in that slot directly and shift-clicking a palette slot will allow you to override the slot with your +currently selected color. diff --git a/worlds/paint/docs/guide_en.md b/worlds/paint/docs/guide_en.md new file mode 100644 index 0000000000..8571ad3d4d --- /dev/null +++ b/worlds/paint/docs/guide_en.md @@ -0,0 +1,8 @@ +# Paint Randomizer Start Guide + +After rolling your seed, go to the [Archipelago Paint](https://mariomantaw.github.io/jspaint/) site and enter the +server details, your slot name, and a room password if one is required. Then click "Connect". If desired, you may then +load a custom target image with File->Open Goal Image. If playing asynchronously, note that progress is saved using the +hash that will appear at the end of the URL so it is recommended to leave the tab open or save the URL with the hash to +avoid losing progress. + diff --git a/worlds/paint/items.py b/worlds/paint/items.py new file mode 100644 index 0000000000..c2ea2001b6 --- /dev/null +++ b/worlds/paint/items.py @@ -0,0 +1,48 @@ +from typing import NamedTuple, Dict + +from BaseClasses import Item, ItemClassification + + +class PaintItem(Item): + game = "Paint" + + +class PaintItemData(NamedTuple): + code: int + type: ItemClassification + + +item_data_table: Dict[str, PaintItemData] = { + "Progressive Canvas Width": PaintItemData(198501, ItemClassification.progression), + "Progressive Canvas Height": PaintItemData(198502, ItemClassification.progression), + "Progressive Color Depth (Red)": PaintItemData(198503, ItemClassification.progression), + "Progressive Color Depth (Green)": PaintItemData(198504, ItemClassification.progression), + "Progressive Color Depth (Blue)": PaintItemData(198505, ItemClassification.progression), + "Free-Form Select": PaintItemData(198506, ItemClassification.useful), + "Select": PaintItemData(198507, ItemClassification.useful), + "Eraser/Color Eraser": PaintItemData(198508, ItemClassification.useful), + "Fill With Color": PaintItemData(198509, ItemClassification.useful), + "Pick Color": PaintItemData(198510, ItemClassification.progression), + "Magnifier": PaintItemData(198511, ItemClassification.useful), + "Pencil": PaintItemData(198512, ItemClassification.useful), + "Brush": PaintItemData(198513, ItemClassification.useful), + "Airbrush": PaintItemData(198514, ItemClassification.useful), + "Text": PaintItemData(198515, ItemClassification.useful), + "Line": PaintItemData(198516, ItemClassification.useful), + "Curve": PaintItemData(198517, ItemClassification.useful), + "Rectangle": PaintItemData(198518, ItemClassification.useful), + "Polygon": PaintItemData(198519, ItemClassification.useful), + "Ellipse": PaintItemData(198520, ItemClassification.useful), + "Rounded Rectangle": PaintItemData(198521, ItemClassification.useful), + # "Change Background Color": PaintItemData(198522, ItemClassification.useful), + "Additional Palette Color": PaintItemData(198523, ItemClassification.filler), + "Undo Trap": PaintItemData(198524, ItemClassification.trap), + "Clear Image Trap": PaintItemData(198525, ItemClassification.trap), + "Invert Colors Trap": PaintItemData(198526, ItemClassification.trap), + "Flip Horizontal Trap": PaintItemData(198527, ItemClassification.trap), + "Flip Vertical Trap": PaintItemData(198528, ItemClassification.trap), +} + +item_table = {name: data.code for name, data in item_data_table.items()} +traps = ["Undo Trap", "Clear Image Trap", "Invert Colors Trap", "Flip Horizontal Trap", "Flip Vertical Trap"] +deathlink_traps = ["Invert Colors Trap", "Flip Horizontal Trap", "Flip Vertical Trap"] diff --git a/worlds/paint/locations.py b/worlds/paint/locations.py new file mode 100644 index 0000000000..ce227991ef --- /dev/null +++ b/worlds/paint/locations.py @@ -0,0 +1,24 @@ +from typing import NamedTuple, Dict + +from BaseClasses import CollectionState, Location + + +class PaintLocation(Location): + game = "Paint" + def access_rule(self, state: CollectionState): + from .rules import paint_percent_available + return paint_percent_available(state, state.multiworld.worlds[self.player], self.player) >=\ + (self.address % 198600) / 4 + + +class PaintLocationData(NamedTuple): + region: str + address: int + + +location_data_table: Dict[str, PaintLocationData] = { + # f"Similarity: {i}%": PaintLocationData("Canvas", 198500 + i) for i in range(1, 96) + f"Similarity: {i/4}%": PaintLocationData("Canvas", 198600 + i) for i in range(1, 381) +} + +location_table = {name: data.address for name, data in location_data_table.items()} diff --git a/worlds/paint/options.py b/worlds/paint/options.py new file mode 100644 index 0000000000..95dee7d8fd --- /dev/null +++ b/worlds/paint/options.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +from Options import Range, PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Visibility + + +class LogicPercent(Range): + """Sets the maximum percent similarity required for a check to be in logic. + Higher values are more difficult and items/locations will not be generated beyond this number.""" + display_name = "Logic Percent" + range_start = 50 + range_end = 95 + default = 80 + + +class GoalPercent(Range): + """Sets the percent similarity required to achieve your goal. + If this number is higher than the value for logic percent, + reaching goal will be in logic upon obtaining all progression items.""" + display_name = "Goal Percent" + range_start = 50 + range_end = 95 + default = 80 + + +class HalfPercentChecks(Range): + """Sets the lowest percent at which locations will be created for each 0.5% of similarity. + Below this number, there will be a check every 1%. + Above this number, there will be a check every 0.5%.""" + display_name = "Half Percent Checks" + range_start = 0 + range_end = 95 + default = 50 + + +class QuarterPercentChecks(Range): + """Sets the lowest percent at which locations will be created for each 0.25% of similarity. + This number will override Half Percent Checks if it is lower.""" + display_name = "Quarter Percent Checks" + range_start = 0 + range_end = 95 + default = 70 + + +class CanvasSizeIncrement(Choice): + """Sets the number of pixels the canvas will expand for each width/height item received. + Ensure an adequate number of locations are generated if setting this below 50.""" + display_name = "Canvas Size Increment" + # option_10 = 10 + # option_20 = 20 + option_25 = 25 + option_50 = 50 + option_100 = 100 + default = 100 + + +class GoalImage(Range): + """Sets the numbered image you will be required to match. + See https://github.com/MarioManTAW/jspaint/tree/master/images/archipelago + for a list of possible images or choose random. + This can also be overwritten client-side by using File->Open.""" + display_name = "Goal Image" + range_start = 1 + range_end = 1 + default = 1 + visibility = Visibility.none + + +class StartingTool(Choice): + """Sets which tool (other than Magnifier) you will be able to use from the start.""" + option_brush = 0 + option_pencil = 1 + option_eraser = 2 + option_airbrush = 3 + option_line = 4 + option_rectangle = 5 + option_ellipse = 6 + option_rounded_rectangle = 7 + default = 0 + + +class TrapCount(Range): + """Sets the percentage of filler items to be replaced by random traps.""" + display_name = "Trap Fill Percent" + range_start = 0 + range_end = 100 + default = 0 + + +class DeathLink(Toggle): + """If on, using the Undo or Clear Image functions will send a death to all other players with death link on. + Receiving a death will clear the image and reset the history. + This option also prevents Undo and Clear Image traps from being generated in the item pool.""" + display_name = "Death Link" + + +@dataclass +class PaintOptions(PerGameCommonOptions): + logic_percent: LogicPercent + goal_percent: GoalPercent + half_percent_checks: HalfPercentChecks + quarter_percent_checks: QuarterPercentChecks + canvas_size_increment: CanvasSizeIncrement + goal_image: GoalImage + starting_tool: StartingTool + trap_count: TrapCount + death_link: DeathLink + start_inventory_from_pool: StartInventoryPool diff --git a/worlds/paint/rules.py b/worlds/paint/rules.py new file mode 100644 index 0000000000..1c7844c129 --- /dev/null +++ b/worlds/paint/rules.py @@ -0,0 +1,40 @@ +from math import sqrt + +from BaseClasses import CollectionState +from . import PaintWorld + + +def paint_percent_available(state: CollectionState, world: PaintWorld, player: int) -> bool: + if state.paint_percent_stale[player]: + state.paint_percent_available[player] = calculate_paint_percent_available(state, world, player) + state.paint_percent_stale[player] = False + return state.paint_percent_available[player] + + +def calculate_paint_percent_available(state: CollectionState, world: PaintWorld, player: int) -> float: + p = state.has("Pick Color", player) + r = min(state.count("Progressive Color Depth (Red)", player), 7) + g = min(state.count("Progressive Color Depth (Green)", player), 7) + b = min(state.count("Progressive Color Depth (Blue)", player), 7) + if not p: + r = min(r, 2) + g = min(g, 2) + b = min(b, 2) + w = state.count("Progressive Canvas Width", player) + h = state.count("Progressive Canvas Height", player) + # This code looks a little messy but it's a mathematical formula derived from the similarity calculations in the + # client. The first line calculates the maximum score achievable for a single pixel with the current items in the + # worst possible case. This per-pixel score is then multiplied by the number of pixels currently available (the + # starting canvas is 400x300) over the total number of pixels with everything unlocked (800x600) to get the + # total score achievable assuming the worst possible target image. Finally, this is multiplied by the logic percent + # option which restricts the logic so as to not require pixel perfection. + return ((1 - ((sqrt(((2 ** (7 - r) - 1) ** 2 + (2 ** (7 - g) - 1) ** 2 + (2 ** (7 - b) - 1) ** 2) * 12)) / 765)) * + min(400 + w * world.options.canvas_size_increment, 800) * + min(300 + h * world.options.canvas_size_increment, 600) * + world.options.logic_percent / 480000) + + +def set_completion_rules(world: PaintWorld, player: int) -> None: + world.multiworld.completion_condition[player] = \ + lambda state: (paint_percent_available(state, world, player) >= + min(world.options.logic_percent, world.options.goal_percent)) diff --git a/worlds/pokemon_rb/client.py b/worlds/pokemon_rb/client.py index 97ca126476..2eb56f5398 100644 --- a/worlds/pokemon_rb/client.py +++ b/worlds/pokemon_rb/client.py @@ -23,6 +23,7 @@ DATA_LOCATIONS = { "DexSanityFlag": (0x1A71, 19), "GameStatus": (0x1A84, 0x01), "Money": (0x141F, 3), + "CurrentMap": (0x1436, 1), "ResetCheck": (0x0100, 4), # First and second Vermilion Gym trash can selection. Second is not used, so should always be 0. # First should never be above 0x0F. This is just before Event Flags. @@ -65,6 +66,7 @@ class PokemonRBClient(BizHawkClient): self.banking_command = None self.game_state = False self.last_death_link = 0 + self.current_map = 0 async def validate_rom(self, ctx): game_name = await read(ctx.bizhawk_ctx, [(0x134, 12, "ROM")]) @@ -230,6 +232,10 @@ class PokemonRBClient(BizHawkClient): }]) self.banking_command = None + if data["CurrentMap"][0] != self.current_map: + await ctx.send_msgs([{"cmd": "Bounce", "slots": [ctx.slot], "data": {"currentMap": data["CurrentMap"][0]}}]) + self.current_map = data["CurrentMap"][0] + # VICTORY if data["EventFlag"][280] & 1 and not ctx.finished_game: diff --git a/worlds/pokemon_rb/docs/setup_en.md b/worlds/pokemon_rb/docs/setup_en.md index 773fb14da9..7e05c8c782 100644 --- a/worlds/pokemon_rb/docs/setup_en.md +++ b/worlds/pokemon_rb/docs/setup_en.md @@ -15,7 +15,7 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst ## Optional Software -- [Pokémon Red and Blue Archipelago Map Tracker](https://github.com/coveleski/rb_tracker/releases/latest), for use with [PopTracker](https://github.com/black-sliver/PopTracker/releases) +- [Pokémon Red and Blue Archipelago Map Tracker](https://github.com/palex00/rb_tracker/releases/latest), for use with [PopTracker](https://github.com/black-sliver/PopTracker/releases) ## Configuring BizHawk @@ -109,7 +109,7 @@ server uses password, type in the bottom textfield `/connect
: [p Pokémon Red and Blue has a fully functional map tracker that supports auto-tracking. -1. Download [Pokémon Red and Blue Archipelago Map Tracker](https://github.com/coveleski/rb_tracker/releases/latest) and [PopTracker](https://github.com/black-sliver/PopTracker/releases). +1. Download [Pokémon Red and Blue Archipelago Map Tracker](https://github.com/palex00/rb_tracker/releases/latest) and [PopTracker](https://github.com/black-sliver/PopTracker/releases). 2. Open PopTracker, and load the Pokémon Red and Blue pack. 3. Click on the "AP" symbol at the top. 4. Enter the AP address, slot name and password. diff --git a/worlds/pokemon_rb/docs/setup_es.md b/worlds/pokemon_rb/docs/setup_es.md index 67024c5b52..5c735bfe99 100644 --- a/worlds/pokemon_rb/docs/setup_es.md +++ b/worlds/pokemon_rb/docs/setup_es.md @@ -16,7 +16,7 @@ Al usar BizHawk, esta guía solo es aplicable en los sistemas de Windows y Linux ## Software Opcional -- [Tracker de mapa para Pokémon Red and Blue Archipelago](https://github.com/coveleski/rb_tracker/releases/latest), para usar con [PopTracker](https://github.com/black-sliver/PopTracker/releases) +- [Tracker de mapa para Pokémon Red and Blue Archipelago](https://github.com/palex00/rb_tracker/releases/latest), para usar con [PopTracker](https://github.com/black-sliver/PopTracker/releases) ## Configurando BizHawk @@ -114,7 +114,7 @@ presiona enter (si el servidor usa contraseña, escribe en el campo de texto inf Pokémon Red and Blue tiene un mapa completamente funcional que soporta seguimiento automático. -1. Descarga el [Tracker de mapa para Pokémon Red and Blue Archipelago](https://github.com/coveleski/rb_tracker/releases/latest) y [PopTracker](https://github.com/black-sliver/PopTracker/releases). +1. Descarga el [Tracker de mapa para Pokémon Red and Blue Archipelago](https://github.com/palex00/rb_tracker/releases/latest) y [PopTracker](https://github.com/black-sliver/PopTracker/releases). 2. Abre PopTracker, y carga el pack de Pokémon Red and Blue. 3. Haz clic en el símbolo "AP" en la parte superior. 4. Ingresa la dirección de AP, nombre del slot y contraseña (si es que hay). diff --git a/worlds/raft/__init__.py b/worlds/raft/__init__.py index 3e33b417c0..374d952d46 100644 --- a/worlds/raft/__init__.py +++ b/worlds/raft/__init__.py @@ -40,6 +40,8 @@ class RaftWorld(World): options_dataclass = RaftOptions options: RaftOptions + extraItemNamePool: list[str] | None = None + required_client_version = (0, 3, 4) def create_items(self): @@ -52,52 +54,52 @@ class RaftWorld(World): pool = [] frequencyItems = [] for item in item_table: - raft_item = self.create_item_replaceAsNecessary(item["name"]) + raft_item = self.create_item(self.replace_item_name_as_necessary(item["name"])) if isFillingFrequencies and "Frequency" in item["name"]: frequencyItems.append(raft_item) else: pool.append(raft_item) - extraItemNamePool = [] + self.extraItemNamePool = [] extras = len(location_table) - len(item_table) - 1 # Victory takes up 1 unaccounted-for slot - if extras > 0: - if (self.options.filler_item_types != self.options.filler_item_types.option_duplicates): # Use resource packs - for packItem in resourcePackItems: - for i in range(minimumResourcePackAmount, maximumResourcePackAmount + 1): - extraItemNamePool.append(createResourcePackName(i, packItem)) - if self.options.filler_item_types != self.options.filler_item_types.option_resource_packs: # Use duplicate items - dupeItemPool = item_table.copy() - # Remove frequencies if necessary - if self.options.island_frequency_locations != self.options.island_frequency_locations.option_anywhere: # Not completely random locations - # If we let frequencies stay in with progressive-frequencies, the progressive-frequency item - # will be included 7 times. This is a massive flood of progressive-frequency items, so we - # instead add progressive-frequency as its own item a smaller amount of times to prevent - # flooding the duplicate item pool with them. - if self.options.island_frequency_locations == self.options.island_frequency_locations.option_progressive: - for _ in range(2): - # Progressives are not in item_pool, need to create faux item for duplicate item pool - # This can still be filtered out later by duplicate_items setting - dupeItemPool.append({ "name": "progressive-frequency", "progression": True }) # Progressive frequencies need to be included - # Always remove non-progressive Frequency items - dupeItemPool = (itm for itm in dupeItemPool if "Frequency" not in itm["name"]) - - # Remove progression or non-progression items if necessary - if (self.options.duplicate_items == self.options.duplicate_items.option_progression): # Progression only - dupeItemPool = (itm for itm in dupeItemPool if itm["progression"] == True) - elif (self.options.duplicate_items == self.options.duplicate_items.option_non_progression): # Non-progression only - dupeItemPool = (itm for itm in dupeItemPool if itm["progression"] == False) - - dupeItemPool = list(dupeItemPool) - # Finally, add items as necessary - if len(dupeItemPool) > 0: - for item in dupeItemPool: - extraItemNamePool.append(item["name"]) + if (self.options.filler_item_types != self.options.filler_item_types.option_duplicates): # Use resource packs + for packItem in resourcePackItems: + for i in range(minimumResourcePackAmount, maximumResourcePackAmount + 1): + self.extraItemNamePool.append(createResourcePackName(i, packItem)) + + if self.options.filler_item_types != self.options.filler_item_types.option_resource_packs: # Use duplicate items + dupeItemPool = item_table.copy() + # Remove frequencies if necessary + if self.options.island_frequency_locations != self.options.island_frequency_locations.option_anywhere: # Not completely random locations + # If we let frequencies stay in with progressive-frequencies, the progressive-frequency item + # will be included 7 times. This is a massive flood of progressive-frequency items, so we + # instead add progressive-frequency as its own item a smaller amount of times to prevent + # flooding the duplicate item pool with them. + if self.options.island_frequency_locations == self.options.island_frequency_locations.option_progressive: + for _ in range(2): + # Progressives are not in item_pool, need to create faux item for duplicate item pool + # This can still be filtered out later by duplicate_items setting + dupeItemPool.append({ "name": "progressive-frequency", "progression": True }) # Progressive frequencies need to be included + # Always remove non-progressive Frequency items + dupeItemPool = (itm for itm in dupeItemPool if "Frequency" not in itm["name"]) + + # Remove progression or non-progression items if necessary + if (self.options.duplicate_items == self.options.duplicate_items.option_progression): # Progression only + dupeItemPool = (itm for itm in dupeItemPool if itm["progression"] == True) + elif (self.options.duplicate_items == self.options.duplicate_items.option_non_progression): # Non-progression only + dupeItemPool = (itm for itm in dupeItemPool if itm["progression"] == False) + + dupeItemPool = list(dupeItemPool) + # Finally, add items as necessary + for item in dupeItemPool: + self.extraItemNamePool.append(self.replace_item_name_as_necessary(item["name"])) - if (len(extraItemNamePool) > 0): - for randomItem in self.random.choices(extraItemNamePool, k=extras): - raft_item = self.create_item_replaceAsNecessary(randomItem) - pool.append(raft_item) + assert self.extraItemNamePool, f"Don't know what extra items to create for {self.player_name}." + + for randomItem in self.random.choices(self.extraItemNamePool, k=extras): + raft_item = self.create_item(randomItem) + pool.append(raft_item) self.multiworld.itempool += pool @@ -108,19 +110,35 @@ class RaftWorld(World): if frequencyItems: self.place_frequencyItems(frequencyItems) + def get_filler_item_name(self) -> str: + # A normal Raft world will have an extraItemNamePool defined after create_items. + if self.extraItemNamePool: + return self.random.choice(self.extraItemNamePool) + + # If this is a "fake" world, e.g. item links with link replacement: Resource packs are always be safe to create + minRPSpecified = self.options.minimum_resource_pack_amount.value + maxRPSpecified = self.options.maximum_resource_pack_amount.value + minimumResourcePackAmount = min(minRPSpecified, maxRPSpecified) + maximumResourcePackAmount = max(minRPSpecified, maxRPSpecified) + resource_amount = self.random.randint(minimumResourcePackAmount, maximumResourcePackAmount) + resource_type = self.random.choice(resourcePackItems) + return createResourcePackName(resource_amount, resource_type) + def set_rules(self): set_rules(self.multiworld, self.player) def create_regions(self): create_regions(self.multiworld, self.player) - - def create_item_replaceAsNecessary(self, name: str) -> Item: - isFrequency = "Frequency" in name - shouldUseProgressive = bool((isFrequency and self.options.island_frequency_locations == self.options.island_frequency_locations.option_progressive) - or (not isFrequency and self.options.progressive_items)) - if shouldUseProgressive and name in progressive_table: - name = progressive_table[name] - return self.create_item(name) + + def replace_item_name_as_necessary(self, name: str) -> str: + if name not in progressive_table: + return name + if "Frequency" in name: + if self.options.island_frequency_locations == self.options.island_frequency_locations.option_progressive: + return progressive_table[name] + elif self.options.progressive_items: + return progressive_table[name] + return name def create_item(self, name: str) -> Item: item = lookup_name_to_item[name] diff --git a/worlds/rogue_legacy/Items.py b/worlds/rogue_legacy/Items.py deleted file mode 100644 index efa24df05a..0000000000 --- a/worlds/rogue_legacy/Items.py +++ /dev/null @@ -1,111 +0,0 @@ -from typing import Dict, NamedTuple, Optional - -from BaseClasses import Item, ItemClassification - - -class RLItem(Item): - game: str = "Rogue Legacy" - - -class RLItemData(NamedTuple): - category: str - code: Optional[int] = None - classification: ItemClassification = ItemClassification.filler - max_quantity: int = 1 - weight: int = 1 - - -def get_items_by_category(category: str) -> Dict[str, RLItemData]: - item_dict: Dict[str, RLItemData] = {} - for name, data in item_table.items(): - if data.category == category: - item_dict.setdefault(name, data) - - return item_dict - - -item_table: Dict[str, RLItemData] = { - # Vendors - "Blacksmith": RLItemData("Vendors", 90_000, ItemClassification.progression), - "Enchantress": RLItemData("Vendors", 90_001, ItemClassification.progression), - "Architect": RLItemData("Vendors", 90_002, ItemClassification.useful), - - # Classes - "Progressive Knights": RLItemData("Classes", 90_003, ItemClassification.useful, 2), - "Progressive Mages": RLItemData("Classes", 90_004, ItemClassification.useful, 2), - "Progressive Barbarians": RLItemData("Classes", 90_005, ItemClassification.useful, 2), - "Progressive Knaves": RLItemData("Classes", 90_006, ItemClassification.useful, 2), - "Progressive Shinobis": RLItemData("Classes", 90_007, ItemClassification.useful, 2), - "Progressive Miners": RLItemData("Classes", 90_008, ItemClassification.useful, 2), - "Progressive Liches": RLItemData("Classes", 90_009, ItemClassification.useful, 2), - "Progressive Spellthieves": RLItemData("Classes", 90_010, ItemClassification.useful, 2), - "Dragons": RLItemData("Classes", 90_096, ItemClassification.progression), - "Traitors": RLItemData("Classes", 90_097, ItemClassification.useful), - - # Skills - "Health Up": RLItemData("Skills", 90_013, ItemClassification.progression_skip_balancing, 15), - "Mana Up": RLItemData("Skills", 90_014, ItemClassification.progression_skip_balancing, 15), - "Attack Up": RLItemData("Skills", 90_015, ItemClassification.progression_skip_balancing, 15), - "Magic Damage Up": RLItemData("Skills", 90_016, ItemClassification.progression_skip_balancing, 15), - "Armor Up": RLItemData("Skills", 90_017, ItemClassification.useful, 15), - "Equip Up": RLItemData("Skills", 90_018, ItemClassification.useful, 5), - "Crit Chance Up": RLItemData("Skills", 90_019, ItemClassification.useful, 5), - "Crit Damage Up": RLItemData("Skills", 90_020, ItemClassification.useful, 5), - "Down Strike Up": RLItemData("Skills", 90_021), - "Gold Gain Up": RLItemData("Skills", 90_022), - "Potion Efficiency Up": RLItemData("Skills", 90_023), - "Invulnerability Time Up": RLItemData("Skills", 90_024), - "Mana Cost Down": RLItemData("Skills", 90_025), - "Death Defiance": RLItemData("Skills", 90_026, ItemClassification.useful), - "Haggling": RLItemData("Skills", 90_027, ItemClassification.useful), - "Randomize Children": RLItemData("Skills", 90_028, ItemClassification.useful), - - # Blueprints - "Progressive Blueprints": RLItemData("Blueprints", 90_055, ItemClassification.useful, 15), - "Squire Blueprints": RLItemData("Blueprints", 90_040, ItemClassification.useful), - "Silver Blueprints": RLItemData("Blueprints", 90_041, ItemClassification.useful), - "Guardian Blueprints": RLItemData("Blueprints", 90_042, ItemClassification.useful), - "Imperial Blueprints": RLItemData("Blueprints", 90_043, ItemClassification.useful), - "Royal Blueprints": RLItemData("Blueprints", 90_044, ItemClassification.useful), - "Knight Blueprints": RLItemData("Blueprints", 90_045, ItemClassification.useful), - "Ranger Blueprints": RLItemData("Blueprints", 90_046, ItemClassification.useful), - "Sky Blueprints": RLItemData("Blueprints", 90_047, ItemClassification.useful), - "Dragon Blueprints": RLItemData("Blueprints", 90_048, ItemClassification.useful), - "Slayer Blueprints": RLItemData("Blueprints", 90_049, ItemClassification.useful), - "Blood Blueprints": RLItemData("Blueprints", 90_050, ItemClassification.useful), - "Sage Blueprints": RLItemData("Blueprints", 90_051, ItemClassification.useful), - "Retribution Blueprints": RLItemData("Blueprints", 90_052, ItemClassification.useful), - "Holy Blueprints": RLItemData("Blueprints", 90_053, ItemClassification.useful), - "Dark Blueprints": RLItemData("Blueprints", 90_054, ItemClassification.useful), - - # Runes - "Vault Runes": RLItemData("Runes", 90_060, ItemClassification.progression), - "Sprint Runes": RLItemData("Runes", 90_061, ItemClassification.progression), - "Vampire Runes": RLItemData("Runes", 90_062, ItemClassification.useful), - "Sky Runes": RLItemData("Runes", 90_063, ItemClassification.progression), - "Siphon Runes": RLItemData("Runes", 90_064, ItemClassification.useful), - "Retaliation Runes": RLItemData("Runes", 90_065), - "Bounty Runes": RLItemData("Runes", 90_066), - "Haste Runes": RLItemData("Runes", 90_067), - "Curse Runes": RLItemData("Runes", 90_068), - "Grace Runes": RLItemData("Runes", 90_069), - "Balance Runes": RLItemData("Runes", 90_070, ItemClassification.useful), - - # Junk - "Triple Stat Increase": RLItemData("Filler", 90_030, weight=6), - "1000 Gold": RLItemData("Filler", 90_031, weight=3), - "3000 Gold": RLItemData("Filler", 90_032, weight=2), - "5000 Gold": RLItemData("Filler", 90_033, weight=1), -} - -event_item_table: Dict[str, RLItemData] = { - "Defeat Khidr": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Alexander": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Ponce de Leon": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Herodotus": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Neo Khidr": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Alexander IV": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Ponce de Freon": RLItemData("Event", classification=ItemClassification.progression), - "Defeat Astrodotus": RLItemData("Event", classification=ItemClassification.progression), - "Defeat The Fountain": RLItemData("Event", classification=ItemClassification.progression), -} diff --git a/worlds/rogue_legacy/Locations.py b/worlds/rogue_legacy/Locations.py deleted file mode 100644 index db9e1db3b0..0000000000 --- a/worlds/rogue_legacy/Locations.py +++ /dev/null @@ -1,94 +0,0 @@ -from typing import Dict, NamedTuple, Optional - -from BaseClasses import Location - - -class RLLocation(Location): - game: str = "Rogue Legacy" - - -class RLLocationData(NamedTuple): - category: str - code: Optional[int] = None - - -def get_locations_by_category(category: str) -> Dict[str, RLLocationData]: - location_dict: Dict[str, RLLocationData] = {} - for name, data in location_table.items(): - if data.category == category: - location_dict.setdefault(name, data) - - return location_dict - - -location_table: Dict[str, RLLocationData] = { - # Manor Renovation - "Manor - Ground Road": RLLocationData("Manor", 91_000), - "Manor - Main Base": RLLocationData("Manor", 91_001), - "Manor - Main Bottom Window": RLLocationData("Manor", 91_002), - "Manor - Main Top Window": RLLocationData("Manor", 91_003), - "Manor - Main Rooftop": RLLocationData("Manor", 91_004), - "Manor - Left Wing Base": RLLocationData("Manor", 91_005), - "Manor - Left Wing Window": RLLocationData("Manor", 91_006), - "Manor - Left Wing Rooftop": RLLocationData("Manor", 91_007), - "Manor - Left Big Base": RLLocationData("Manor", 91_008), - "Manor - Left Big Upper 1": RLLocationData("Manor", 91_009), - "Manor - Left Big Upper 2": RLLocationData("Manor", 91_010), - "Manor - Left Big Windows": RLLocationData("Manor", 91_011), - "Manor - Left Big Rooftop": RLLocationData("Manor", 91_012), - "Manor - Left Far Base": RLLocationData("Manor", 91_013), - "Manor - Left Far Roof": RLLocationData("Manor", 91_014), - "Manor - Left Extension": RLLocationData("Manor", 91_015), - "Manor - Left Tree 1": RLLocationData("Manor", 91_016), - "Manor - Left Tree 2": RLLocationData("Manor", 91_017), - "Manor - Right Wing Base": RLLocationData("Manor", 91_018), - "Manor - Right Wing Window": RLLocationData("Manor", 91_019), - "Manor - Right Wing Rooftop": RLLocationData("Manor", 91_020), - "Manor - Right Big Base": RLLocationData("Manor", 91_021), - "Manor - Right Big Upper": RLLocationData("Manor", 91_022), - "Manor - Right Big Rooftop": RLLocationData("Manor", 91_023), - "Manor - Right High Base": RLLocationData("Manor", 91_024), - "Manor - Right High Upper": RLLocationData("Manor", 91_025), - "Manor - Right High Tower": RLLocationData("Manor", 91_026), - "Manor - Right Extension": RLLocationData("Manor", 91_027), - "Manor - Right Tree": RLLocationData("Manor", 91_028), - "Manor - Observatory Base": RLLocationData("Manor", 91_029), - "Manor - Observatory Telescope": RLLocationData("Manor", 91_030), - - # Boss Rewards - "Castle Hamson Boss Reward": RLLocationData("Boss", 91_100), - "Forest Abkhazia Boss Reward": RLLocationData("Boss", 91_102), - "The Maya Boss Reward": RLLocationData("Boss", 91_104), - "Land of Darkness Boss Reward": RLLocationData("Boss", 91_106), - - # Special Locations - "Jukebox": RLLocationData("Special", 91_200), - "Painting": RLLocationData("Special", 91_201), - "Cheapskate Elf's Game": RLLocationData("Special", 91_202), - "Carnival": RLLocationData("Special", 91_203), - - # Diaries - **{f"Diary {i+1}": RLLocationData("Diary", 91_300 + i) for i in range(0, 25)}, - - # Chests - **{f"Castle Hamson - Chest {i+1}": RLLocationData("Chests", 91_600 + i) for i in range(0, 50)}, - **{f"Forest Abkhazia - Chest {i+1}": RLLocationData("Chests", 91_700 + i) for i in range(0, 50)}, - **{f"The Maya - Chest {i+1}": RLLocationData("Chests", 91_800 + i) for i in range(0, 50)}, - **{f"Land of Darkness - Chest {i+1}": RLLocationData("Chests", 91_900 + i) for i in range(0, 50)}, - **{f"Chest {i+1}": RLLocationData("Chests", 92_000 + i) for i in range(0, 200)}, - - # Fairy Chests - **{f"Castle Hamson - Fairy Chest {i+1}": RLLocationData("Fairies", 91_400 + i) for i in range(0, 15)}, - **{f"Forest Abkhazia - Fairy Chest {i+1}": RLLocationData("Fairies", 91_450 + i) for i in range(0, 15)}, - **{f"The Maya - Fairy Chest {i+1}": RLLocationData("Fairies", 91_500 + i) for i in range(0, 15)}, - **{f"Land of Darkness - Fairy Chest {i+1}": RLLocationData("Fairies", 91_550 + i) for i in range(0, 15)}, - **{f"Fairy Chest {i+1}": RLLocationData("Fairies", 92_200 + i) for i in range(0, 60)}, -} - -event_location_table: Dict[str, RLLocationData] = { - "Castle Hamson Boss Room": RLLocationData("Event"), - "Forest Abkhazia Boss Room": RLLocationData("Event"), - "The Maya Boss Room": RLLocationData("Event"), - "Land of Darkness Boss Room": RLLocationData("Event"), - "Fountain Room": RLLocationData("Event"), -} diff --git a/worlds/rogue_legacy/Options.py b/worlds/rogue_legacy/Options.py deleted file mode 100644 index 139ff60944..0000000000 --- a/worlds/rogue_legacy/Options.py +++ /dev/null @@ -1,387 +0,0 @@ -from Options import Choice, Range, Toggle, DeathLink, DefaultOnToggle, OptionSet, PerGameCommonOptions - -from dataclasses import dataclass - - -class StartingGender(Choice): - """ - Determines the gender of your initial 'Sir Lee' character. - """ - display_name = "Starting Gender" - option_sir = 0 - option_lady = 1 - alias_male = 0 - alias_female = 1 - default = "random" - - -class StartingClass(Choice): - """ - Determines the starting class of your initial 'Sir Lee' character. - """ - display_name = "Starting Class" - option_knight = 0 - option_mage = 1 - option_barbarian = 2 - option_knave = 3 - option_shinobi = 4 - option_miner = 5 - option_spellthief = 6 - option_lich = 7 - default = 0 - - -class NewGamePlus(Choice): - """ - Puts the castle in new game plus mode which vastly increases enemy level, but increases gold gain by 50%. Not - recommended for those inexperienced to Rogue Legacy! - """ - display_name = "New Game Plus" - option_normal = 0 - option_new_game_plus = 1 - option_new_game_plus_2 = 2 - alias_hard = 1 - alias_brutal = 2 - default = 0 - - -class LevelScaling(Range): - """ - A percentage modifier for scaling enemy level as you continue throughout the castle. 100 means enemies will have - 100% level scaling (normal). Setting this too high will result in enemies with absurdly high levels, you have been - warned. - """ - display_name = "Enemy Level Scaling Percentage" - range_start = 1 - range_end = 300 - default = 100 - - -class FairyChestsPerZone(Range): - """ - Determines the number of Fairy Chests in a given zone that contain items. After these have been checked, only stat - bonuses can be found in Fairy Chests. - """ - display_name = "Fairy Chests Per Zone" - range_start = 0 - range_end = 15 - default = 1 - - -class ChestsPerZone(Range): - """ - Determines the number of Non-Fairy Chests in a given zone that contain items. After these have been checked, only - gold or stat bonuses can be found in Chests. - """ - display_name = "Chests Per Zone" - range_start = 20 - range_end = 50 - default = 20 - - -class UniversalFairyChests(Toggle): - """ - Determines if fairy chests should be combined into one pool instead of per zone, similar to Risk of Rain 2. - """ - display_name = "Universal Fairy Chests" - - -class UniversalChests(Toggle): - """ - Determines if non-fairy chests should be combined into one pool instead of per zone, similar to Risk of Rain 2. - """ - display_name = "Universal Non-Fairy Chests" - - -class Vendors(Choice): - """ - Determines where to place the Blacksmith and Enchantress unlocks in logic (or start with them unlocked). - """ - display_name = "Vendors" - option_start_unlocked = 0 - option_early = 1 - option_normal = 2 - option_anywhere = 3 - default = 1 - - -class Architect(Choice): - """ - Determines where the Architect sits in the item pool. - """ - display_name = "Architect" - option_start_unlocked = 0 - option_early = 1 - option_anywhere = 2 - option_disabled = 3 - alias_normal = 2 - default = 2 - - -class ArchitectFee(Range): - """ - Determines how large of a percentage the architect takes from the player when utilizing his services. 100 means he - takes all your gold. 0 means his services are free. - """ - display_name = "Architect Fee Percentage" - range_start = 0 - range_end = 100 - default = 40 - - -class DisableCharon(Toggle): - """ - Prevents Charon from taking your money when you re-enter the castle. Also removes Haggling from the Item Pool. - """ - display_name = "Disable Charon" - - -class RequirePurchasing(DefaultOnToggle): - """ - Determines where you will be required to purchase equipment and runes from the Blacksmith and Enchantress before - equipping them. If you disable require purchasing, Manor Renovations are scaled to take this into account. - """ - display_name = "Require Purchasing" - - -class ProgressiveBlueprints(Toggle): - """ - Instead of shuffling blueprints randomly into the pool, blueprint unlocks are progressively unlocked. You would get - Squire first, then Knight, etc., until finally Dark. - """ - display_name = "Progressive Blueprints" - - -class GoldGainMultiplier(Choice): - """ - Adjusts the multiplier for gaining gold from all sources. - """ - display_name = "Gold Gain Multiplier" - option_normal = 0 - option_quarter = 1 - option_half = 2 - option_double = 3 - option_quadruple = 4 - default = 0 - - -class NumberOfChildren(Range): - """ - Determines the number of offspring you can choose from on the lineage screen after a death. - """ - display_name = "Number of Children" - range_start = 1 - range_end = 5 - default = 3 - - -class AdditionalLadyNames(OptionSet): - """ - Set of additional names your potential offspring can have. If Allow Default Names is disabled, this is the only list - of names your children can have. The first value will also be your initial character's name depending on Starting - Gender. - """ - display_name = "Additional Lady Names" - -class AdditionalSirNames(OptionSet): - """ - Set of additional names your potential offspring can have. If Allow Default Names is disabled, this is the only list - of names your children can have. The first value will also be your initial character's name depending on Starting - Gender. - """ - display_name = "Additional Sir Names" - - -class AllowDefaultNames(DefaultOnToggle): - """ - Determines if the default names defined in the vanilla game are allowed to be used. Warning: Your world will not - generate if the number of Additional Names defined is less than the Number of Children value. - """ - display_name = "Allow Default Names" - - -class CastleScaling(Range): - """ - Adjusts the scaling factor for how big a castle can be. Larger castles scale enemies quicker and also take longer - to generate. 100 means normal castle size. - """ - display_name = "Castle Size Scaling Percentage" - range_start = 50 - range_end = 300 - default = 100 - - -class ChallengeBossKhidr(Choice): - """ - Determines if Neo Khidr replaces Khidr in their boss room. - """ - display_name = "Khidr" - option_vanilla = 0 - option_challenge = 1 - default = 0 - - -class ChallengeBossAlexander(Choice): - """ - Determines if Alexander the IV replaces Alexander in their boss room. - """ - display_name = "Alexander" - option_vanilla = 0 - option_challenge = 1 - default = 0 - - -class ChallengeBossLeon(Choice): - """ - Determines if Ponce de Freon replaces Ponce de Leon in their boss room. - """ - display_name = "Ponce de Leon" - option_vanilla = 0 - option_challenge = 1 - default = 0 - - -class ChallengeBossHerodotus(Choice): - """ - Determines if Astrodotus replaces Herodotus in their boss room. - """ - display_name = "Herodotus" - option_vanilla = 0 - option_challenge = 1 - default = 0 - - -class HealthUpPool(Range): - """ - Determines the number of Health Ups in the item pool. - """ - display_name = "Health Up Pool" - range_start = 0 - range_end = 15 - default = 15 - - -class ManaUpPool(Range): - """ - Determines the number of Mana Ups in the item pool. - """ - display_name = "Mana Up Pool" - range_start = 0 - range_end = 15 - default = 15 - - -class AttackUpPool(Range): - """ - Determines the number of Attack Ups in the item pool. - """ - display_name = "Attack Up Pool" - range_start = 0 - range_end = 15 - default = 15 - - -class MagicDamageUpPool(Range): - """ - Determines the number of Magic Damage Ups in the item pool. - """ - display_name = "Magic Damage Up Pool" - range_start = 0 - range_end = 15 - default = 15 - - -class ArmorUpPool(Range): - """ - Determines the number of Armor Ups in the item pool. - """ - display_name = "Armor Up Pool" - range_start = 0 - range_end = 10 - default = 10 - - -class EquipUpPool(Range): - """ - Determines the number of Equip Ups in the item pool. - """ - display_name = "Equip Up Pool" - range_start = 0 - range_end = 10 - default = 10 - - -class CritChanceUpPool(Range): - """ - Determines the number of Crit Chance Ups in the item pool. - """ - display_name = "Crit Chance Up Pool" - range_start = 0 - range_end = 5 - default = 5 - - -class CritDamageUpPool(Range): - """ - Determines the number of Crit Damage Ups in the item pool. - """ - display_name = "Crit Damage Up Pool" - range_start = 0 - range_end = 5 - default = 5 - - -class FreeDiaryOnGeneration(DefaultOnToggle): - """ - Allows the player to get a free diary check every time they regenerate the castle in the starting room. - """ - display_name = "Free Diary On Generation" - - -class AvailableClasses(OptionSet): - """ - List of classes that will be in the item pool to find. The upgraded form of the class will be added with it. - The upgraded form of your starting class will be available regardless. - """ - display_name = "Available Classes" - default = frozenset( - {"Knight", "Mage", "Barbarian", "Knave", "Shinobi", "Miner", "Spellthief", "Lich", "Dragon", "Traitor"} - ) - valid_keys = {"Knight", "Mage", "Barbarian", "Knave", "Shinobi", "Miner", "Spellthief", "Lich", "Dragon", "Traitor"} - - -@dataclass -class RLOptions(PerGameCommonOptions): - starting_gender: StartingGender - starting_class: StartingClass - available_classes: AvailableClasses - new_game_plus: NewGamePlus - fairy_chests_per_zone: FairyChestsPerZone - chests_per_zone: ChestsPerZone - universal_fairy_chests: UniversalFairyChests - universal_chests: UniversalChests - vendors: Vendors - architect: Architect - architect_fee: ArchitectFee - disable_charon: DisableCharon - require_purchasing: RequirePurchasing - progressive_blueprints: ProgressiveBlueprints - gold_gain_multiplier: GoldGainMultiplier - number_of_children: NumberOfChildren - free_diary_on_generation: FreeDiaryOnGeneration - khidr: ChallengeBossKhidr - alexander: ChallengeBossAlexander - leon: ChallengeBossLeon - herodotus: ChallengeBossHerodotus - health_pool: HealthUpPool - mana_pool: ManaUpPool - attack_pool: AttackUpPool - magic_damage_pool: MagicDamageUpPool - armor_pool: ArmorUpPool - equip_pool: EquipUpPool - crit_chance_pool: CritChanceUpPool - crit_damage_pool: CritDamageUpPool - allow_default_names: AllowDefaultNames - additional_lady_names: AdditionalLadyNames - additional_sir_names: AdditionalSirNames - death_link: DeathLink diff --git a/worlds/rogue_legacy/Presets.py b/worlds/rogue_legacy/Presets.py deleted file mode 100644 index 2dfeee64d8..0000000000 --- a/worlds/rogue_legacy/Presets.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Any, Dict - -from .Options import Architect, GoldGainMultiplier, Vendors - -rl_options_presets: Dict[str, Dict[str, Any]] = { - # Example preset using only literal values. - "Unknown Fate": { - "progression_balancing": "random", - "accessibility": "random", - "starting_gender": "random", - "starting_class": "random", - "new_game_plus": "random", - "fairy_chests_per_zone": "random", - "chests_per_zone": "random", - "universal_fairy_chests": "random", - "universal_chests": "random", - "vendors": "random", - "architect": "random", - "architect_fee": "random", - "disable_charon": "random", - "require_purchasing": "random", - "progressive_blueprints": "random", - "gold_gain_multiplier": "random", - "number_of_children": "random", - "free_diary_on_generation": "random", - "khidr": "random", - "alexander": "random", - "leon": "random", - "herodotus": "random", - "health_pool": "random", - "mana_pool": "random", - "attack_pool": "random", - "magic_damage_pool": "random", - "armor_pool": "random", - "equip_pool": "random", - "crit_chance_pool": "random", - "crit_damage_pool": "random", - "allow_default_names": True, - "death_link": "random", - }, - # A preset I actually use, using some literal values and some from the option itself. - "Limited Potential": { - "progression_balancing": "disabled", - "fairy_chests_per_zone": 2, - "starting_class": "random", - "chests_per_zone": 30, - "vendors": Vendors.option_normal, - "architect": Architect.option_disabled, - "gold_gain_multiplier": GoldGainMultiplier.option_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, - } -} diff --git a/worlds/rogue_legacy/Regions.py b/worlds/rogue_legacy/Regions.py deleted file mode 100644 index 61b0ef73ec..0000000000 --- a/worlds/rogue_legacy/Regions.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Dict, List, NamedTuple, Optional, TYPE_CHECKING - -from BaseClasses import MultiWorld, Region, Entrance -from .Locations import RLLocation, location_table, get_locations_by_category - -if TYPE_CHECKING: - from . import RLWorld - - -class RLRegionData(NamedTuple): - locations: Optional[List[str]] - region_exits: Optional[List[str]] - - -def create_regions(world: "RLWorld"): - regions: Dict[str, RLRegionData] = { - "Menu": RLRegionData(None, ["Castle Hamson"]), - "The Manor": RLRegionData([], []), - "Castle Hamson": RLRegionData([], ["Forest Abkhazia", "The Maya", "Land of Darkness", - "The Fountain Room", "The Manor"]), - "Forest Abkhazia": RLRegionData([], []), - "The Maya": RLRegionData([], []), - "Land of Darkness": RLRegionData([], []), - "The Fountain Room": RLRegionData([], None), - } - - # Artificially stagger diary spheres for progression. - for diary in range(0, 25): - region: str - if 0 <= diary < 6: - region = "Castle Hamson" - elif 6 <= diary < 12: - region = "Forest Abkhazia" - elif 12 <= diary < 18: - region = "The Maya" - elif 18 <= diary < 24: - region = "Land of Darkness" - else: - region = "The Fountain Room" - regions[region].locations.append(f"Diary {diary + 1}") - - # Manor & Special - for manor in get_locations_by_category("Manor").keys(): - regions["The Manor"].locations.append(manor) - for special in get_locations_by_category("Special").keys(): - regions["Castle Hamson"].locations.append(special) - - # Boss Rewards - regions["Castle Hamson"].locations.append("Castle Hamson Boss Reward") - regions["Forest Abkhazia"].locations.append("Forest Abkhazia Boss Reward") - regions["The Maya"].locations.append("The Maya Boss Reward") - regions["Land of Darkness"].locations.append("Land of Darkness Boss Reward") - - # Events - regions["Castle Hamson"].locations.append("Castle Hamson Boss Room") - regions["Forest Abkhazia"].locations.append("Forest Abkhazia Boss Room") - regions["The Maya"].locations.append("The Maya Boss Room") - regions["Land of Darkness"].locations.append("Land of Darkness Boss Room") - regions["The Fountain Room"].locations.append("Fountain Room") - - # Chests - chests = int(world.options.chests_per_zone) - for i in range(0, chests): - if world.options.universal_chests: - regions["Castle Hamson"].locations.append(f"Chest {i + 1}") - regions["Forest Abkhazia"].locations.append(f"Chest {i + 1 + chests}") - regions["The Maya"].locations.append(f"Chest {i + 1 + (chests * 2)}") - regions["Land of Darkness"].locations.append(f"Chest {i + 1 + (chests * 3)}") - else: - regions["Castle Hamson"].locations.append(f"Castle Hamson - Chest {i + 1}") - regions["Forest Abkhazia"].locations.append(f"Forest Abkhazia - Chest {i + 1}") - regions["The Maya"].locations.append(f"The Maya - Chest {i + 1}") - regions["Land of Darkness"].locations.append(f"Land of Darkness - Chest {i + 1}") - - # Fairy Chests - chests = int(world.options.fairy_chests_per_zone) - for i in range(0, chests): - if world.options.universal_fairy_chests: - regions["Castle Hamson"].locations.append(f"Fairy Chest {i + 1}") - regions["Forest Abkhazia"].locations.append(f"Fairy Chest {i + 1 + chests}") - regions["The Maya"].locations.append(f"Fairy Chest {i + 1 + (chests * 2)}") - regions["Land of Darkness"].locations.append(f"Fairy Chest {i + 1 + (chests * 3)}") - else: - regions["Castle Hamson"].locations.append(f"Castle Hamson - Fairy Chest {i + 1}") - regions["Forest Abkhazia"].locations.append(f"Forest Abkhazia - Fairy Chest {i + 1}") - regions["The Maya"].locations.append(f"The Maya - Fairy Chest {i + 1}") - regions["Land of Darkness"].locations.append(f"Land of Darkness - Fairy Chest {i + 1}") - - # Set up the regions correctly. - for name, data in regions.items(): - world.multiworld.regions.append(create_region(world.multiworld, world.player, name, data)) - - world.get_entrance("Castle Hamson").connect(world.get_region("Castle Hamson")) - world.get_entrance("The Manor").connect(world.get_region("The Manor")) - world.get_entrance("Forest Abkhazia").connect(world.get_region("Forest Abkhazia")) - world.get_entrance("The Maya").connect(world.get_region("The Maya")) - world.get_entrance("Land of Darkness").connect(world.get_region("Land of Darkness")) - world.get_entrance("The Fountain Room").connect(world.get_region("The Fountain Room")) - - -def create_region(multiworld: MultiWorld, player: int, name: str, data: RLRegionData): - region = Region(name, player, multiworld) - if data.locations: - for loc_name in data.locations: - loc_data = location_table.get(loc_name) - location = RLLocation(player, loc_name, loc_data.code if loc_data else None, region) - region.locations.append(location) - - if data.region_exits: - for exit in data.region_exits: - entrance = Entrance(player, exit, region) - region.exits.append(entrance) - - return region diff --git a/worlds/rogue_legacy/Rules.py b/worlds/rogue_legacy/Rules.py deleted file mode 100644 index 505bbdd635..0000000000 --- a/worlds/rogue_legacy/Rules.py +++ /dev/null @@ -1,117 +0,0 @@ -from BaseClasses import CollectionState -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from . import RLWorld - - -def get_upgrade_total(world: "RLWorld") -> int: - return int(world.options.health_pool) + int(world.options.mana_pool) + \ - int(world.options.attack_pool) + int(world.options.magic_damage_pool) - - -def get_upgrade_count(state: CollectionState, player: int) -> int: - return state.count("Health Up", player) + state.count("Mana Up", player) + \ - state.count("Attack Up", player) + state.count("Magic Damage Up", player) - - -def has_vendors(state: CollectionState, player: int) -> bool: - return state.has_all({"Blacksmith", "Enchantress"}, player) - - -def has_upgrade_amount(state: CollectionState, player: int, amount: int) -> bool: - return get_upgrade_count(state, player) >= amount - - -def has_upgrades_percentage(state: CollectionState, world: "RLWorld", percentage: float) -> bool: - return has_upgrade_amount(state, world.player, round(get_upgrade_total(world) * (percentage / 100))) - - -def has_movement_rune(state: CollectionState, player: int) -> bool: - return state.has("Vault Runes", player) or state.has("Sprint Runes", player) or state.has("Sky Runes", player) - - -def has_fairy_progression(state: CollectionState, player: int) -> bool: - return state.has("Dragons", player) or (state.has("Enchantress", player) and has_movement_rune(state, player)) - - -def has_defeated_castle(state: CollectionState, player: int) -> bool: - return state.has("Defeat Khidr", player) or state.has("Defeat Neo Khidr", player) - - -def has_defeated_forest(state: CollectionState, player: int) -> bool: - return state.has("Defeat Alexander", player) or state.has("Defeat Alexander IV", player) - - -def has_defeated_tower(state: CollectionState, player: int) -> bool: - return state.has("Defeat Ponce de Leon", player) or state.has("Defeat Ponce de Freon", player) - - -def has_defeated_dungeon(state: CollectionState, player: int) -> bool: - return state.has("Defeat Herodotus", player) or state.has("Defeat Astrodotus", player) - - -def set_rules(world: "RLWorld", player: int): - # If 'vendors' are 'normal', then expect it to show up in the first half(ish) of the spheres. - if world.options.vendors == "normal": - world.get_location("Forest Abkhazia Boss Reward").access_rule = \ - lambda state: has_vendors(state, player) - - # Gate each manor location so everything isn't dumped into sphere 1. - manor_rules = { - "Defeat Khidr" if world.options.khidr == "vanilla" else "Defeat Neo Khidr": [ - "Manor - Left Wing Window", - "Manor - Left Wing Rooftop", - "Manor - Right Wing Window", - "Manor - Right Wing Rooftop", - "Manor - Left Big Base", - "Manor - Right Big Base", - "Manor - Left Tree 1", - "Manor - Left Tree 2", - "Manor - Right Tree", - ], - "Defeat Alexander" if world.options.alexander == "vanilla" else "Defeat Alexander IV": [ - "Manor - Left Big Upper 1", - "Manor - Left Big Upper 2", - "Manor - Left Big Windows", - "Manor - Left Big Rooftop", - "Manor - Left Far Base", - "Manor - Left Far Roof", - "Manor - Left Extension", - "Manor - Right Big Upper", - "Manor - Right Big Rooftop", - "Manor - Right Extension", - ], - "Defeat Ponce de Leon" if world.options.leon == "vanilla" else "Defeat Ponce de Freon": [ - "Manor - Right High Base", - "Manor - Right High Upper", - "Manor - Right High Tower", - "Manor - Observatory Base", - "Manor - Observatory Telescope", - ] - } - - # Set rules for manor locations. - for event, locations in manor_rules.items(): - for location in locations: - world.get_location(location).access_rule = lambda state: state.has(event, player) - - # Set rules for fairy chests to decrease headache of expectation to find non-movement fairy chests. - for fairy_location in [location for location in world.multiworld.get_locations(player) if "Fairy" in location.name]: - fairy_location.access_rule = lambda state: has_fairy_progression(state, player) - - # Region rules. - world.get_entrance("Forest Abkhazia").access_rule = \ - lambda state: has_upgrades_percentage(state, world, 12.5) and has_defeated_castle(state, player) - - world.get_entrance("The Maya").access_rule = \ - lambda state: has_upgrades_percentage(state, world, 25) and has_defeated_forest(state, player) - - world.get_entrance("Land of Darkness").access_rule = \ - lambda state: has_upgrades_percentage(state, world, 37.5) and has_defeated_tower(state, player) - - world.get_entrance("The Fountain Room").access_rule = \ - lambda state: has_upgrades_percentage(state, world, 50) and has_defeated_dungeon(state, player) - - # Win condition. - world.multiworld.completion_condition[player] = lambda state: state.has("Defeat The Fountain", player) diff --git a/worlds/rogue_legacy/__init__.py b/worlds/rogue_legacy/__init__.py deleted file mode 100644 index 7ffdd459db..0000000000 --- a/worlds/rogue_legacy/__init__.py +++ /dev/null @@ -1,243 +0,0 @@ -from typing import List - -from BaseClasses import Tutorial -from worlds.AutoWorld import WebWorld, World -from .Items import RLItem, RLItemData, event_item_table, get_items_by_category, item_table -from .Locations import RLLocation, location_table -from .Options import RLOptions -from .Presets import rl_options_presets -from .Regions import create_regions -from .Rules import set_rules - - -class RLWeb(WebWorld): - theme = "stone" - tutorials = [Tutorial( - "Multiworld Setup Guide", - "A guide to setting up the Rogue Legacy Randomizer software on your computer. This guide covers single-player, " - "multiworld, and related software.", - "English", - "rogue-legacy_en.md", - "rogue-legacy/en", - ["Phar"] - )] - bug_report_page = "https://github.com/ThePhar/RogueLegacyRandomizer/issues/new?assignees=&labels=bug&template=" \ - "report-an-issue---.md&title=%5BIssue%5D" - options_presets = rl_options_presets - - -class RLWorld(World): - """ - Rogue Legacy is a genealogical rogue-"LITE" where anyone can be a hero. Each time you die, your child will succeed - you. Every child is unique. One child might be colorblind, another might have vertigo-- they could even be a dwarf. - But that's OK, because no one is perfect, and you don't have to be to succeed. - """ - game = "Rogue Legacy" - options_dataclass = RLOptions - options: RLOptions - topology_present = True - required_client_version = (0, 3, 5) - web = RLWeb() - - item_name_to_id = {name: data.code for name, data in item_table.items() if data.code is not None} - location_name_to_id = {name: data.code for name, data in location_table.items() if data.code is not None} - - def fill_slot_data(self) -> dict: - return self.options.as_dict(*[name for name in self.options_dataclass.type_hints.keys()]) - - def generate_early(self): - # Check validation of names. - additional_lady_names = len(self.options.additional_lady_names.value) - additional_sir_names = len(self.options.additional_sir_names.value) - if not self.options.allow_default_names: - if additional_lady_names < int(self.options.number_of_children): - raise Exception( - f"allow_default_names is off, but not enough names are defined in additional_lady_names. " - f"Expected {int(self.options.number_of_children)}, Got {additional_lady_names}") - - if additional_sir_names < int(self.options.number_of_children): - raise Exception( - f"allow_default_names is off, but not enough names are defined in additional_sir_names. " - f"Expected {int(self.options.number_of_children)}, Got {additional_sir_names}") - - def create_items(self): - item_pool: List[RLItem] = [] - total_locations = len(self.multiworld.get_unfilled_locations(self.player)) - for name, data in item_table.items(): - quantity = data.max_quantity - - # Architect - if name == "Architect": - if self.options.architect == "disabled": - continue - if self.options.architect == "start_unlocked": - self.multiworld.push_precollected(self.create_item(name)) - continue - if self.options.architect == "early": - self.multiworld.local_early_items[self.player]["Architect"] = 1 - - # Blacksmith and Enchantress - if name == "Blacksmith" or name == "Enchantress": - if self.options.vendors == "start_unlocked": - self.multiworld.push_precollected(self.create_item(name)) - continue - if self.options.vendors == "early": - self.multiworld.local_early_items[self.player]["Blacksmith"] = 1 - self.multiworld.local_early_items[self.player]["Enchantress"] = 1 - - # Haggling - if name == "Haggling" and self.options.disable_charon: - continue - - # Blueprints - if data.category == "Blueprints": - # No progressive blueprints if progressive_blueprints are disabled. - if name == "Progressive Blueprints" and not self.options.progressive_blueprints: - continue - # No distinct blueprints if progressive_blueprints are enabled. - elif name != "Progressive Blueprints" and self.options.progressive_blueprints: - continue - - # Classes - if data.category == "Classes": - if name == "Progressive Knights": - if "Knight" not in self.options.available_classes: - continue - - if self.options.starting_class == "knight": - quantity = 1 - if name == "Progressive Mages": - if "Mage" not in self.options.available_classes: - continue - - if self.options.starting_class == "mage": - quantity = 1 - if name == "Progressive Barbarians": - if "Barbarian" not in self.options.available_classes: - continue - - if self.options.starting_class == "barbarian": - quantity = 1 - if name == "Progressive Knaves": - if "Knave" not in self.options.available_classes: - continue - - if self.options.starting_class == "knave": - quantity = 1 - if name == "Progressive Miners": - if "Miner" not in self.options.available_classes: - continue - - if self.options.starting_class == "miner": - quantity = 1 - if name == "Progressive Shinobis": - if "Shinobi" not in self.options.available_classes: - continue - - if self.options.starting_class == "shinobi": - quantity = 1 - if name == "Progressive Liches": - if "Lich" not in self.options.available_classes: - continue - - if self.options.starting_class == "lich": - quantity = 1 - if name == "Progressive Spellthieves": - if "Spellthief" not in self.options.available_classes: - continue - - if self.options.starting_class == "spellthief": - quantity = 1 - if name == "Dragons": - if "Dragon" not in self.options.available_classes: - continue - if name == "Traitors": - if "Traitor" not in self.options.available_classes: - continue - - # Skills - if name == "Health Up": - quantity = self.options.health_pool.value - elif name == "Mana Up": - quantity = self.options.mana_pool.value - elif name == "Attack Up": - quantity = self.options.attack_pool.value - elif name == "Magic Damage Up": - quantity = self.options.magic_damage_pool.value - elif name == "Armor Up": - quantity = self.options.armor_pool.value - elif name == "Equip Up": - quantity = self.options.equip_pool.value - elif name == "Crit Chance Up": - quantity = self.options.crit_chance_pool.value - elif name == "Crit Damage Up": - quantity = self.options.crit_damage_pool.value - - # Ignore filler, it will be added in a later stage. - if data.category == "Filler": - continue - - item_pool += [self.create_item(name) for _ in range(0, quantity)] - - # Fill any empty locations with filler items. - while len(item_pool) < total_locations: - item_pool.append(self.create_item(self.get_filler_item_name())) - - self.multiworld.itempool += item_pool - - def get_filler_item_name(self) -> str: - fillers = get_items_by_category("Filler") - weights = [data.weight for data in fillers.values()] - return self.random.choices([filler for filler in fillers.keys()], weights, k=1)[0] - - def create_item(self, name: str) -> RLItem: - data = item_table[name] - return RLItem(name, data.classification, data.code, self.player) - - def create_event(self, name: str) -> RLItem: - data = event_item_table[name] - return RLItem(name, data.classification, data.code, self.player) - - def set_rules(self): - set_rules(self, self.player) - - def create_regions(self): - create_regions(self) - self._place_events() - - def _place_events(self): - # Fountain - self.multiworld.get_location("Fountain Room", self.player).place_locked_item( - self.create_event("Defeat The Fountain")) - - # Khidr / Neo Khidr - if self.options.khidr == "vanilla": - self.multiworld.get_location("Castle Hamson Boss Room", self.player).place_locked_item( - self.create_event("Defeat Khidr")) - else: - self.multiworld.get_location("Castle Hamson Boss Room", self.player).place_locked_item( - self.create_event("Defeat Neo Khidr")) - - # Alexander / Alexander IV - if self.options.alexander == "vanilla": - self.multiworld.get_location("Forest Abkhazia Boss Room", self.player).place_locked_item( - self.create_event("Defeat Alexander")) - else: - self.multiworld.get_location("Forest Abkhazia Boss Room", self.player).place_locked_item( - self.create_event("Defeat Alexander IV")) - - # Ponce de Leon / Ponce de Freon - if self.options.leon == "vanilla": - self.multiworld.get_location("The Maya Boss Room", self.player).place_locked_item( - self.create_event("Defeat Ponce de Leon")) - else: - self.multiworld.get_location("The Maya Boss Room", self.player).place_locked_item( - self.create_event("Defeat Ponce de Freon")) - - # Herodotus / Astrodotus - if self.options.herodotus == "vanilla": - self.multiworld.get_location("Land of Darkness Boss Room", self.player).place_locked_item( - self.create_event("Defeat Herodotus")) - else: - self.multiworld.get_location("Land of Darkness Boss Room", self.player).place_locked_item( - self.create_event("Defeat Astrodotus")) diff --git a/worlds/rogue_legacy/docs/en_Rogue Legacy.md b/worlds/rogue_legacy/docs/en_Rogue Legacy.md deleted file mode 100644 index dd203c73ac..0000000000 --- a/worlds/rogue_legacy/docs/en_Rogue Legacy.md +++ /dev/null @@ -1,34 +0,0 @@ -# Rogue Legacy (PC) - -## Where is the options page? - -The [player options page for this game](../player-options) contains most of the options you need to -configure and export a config file. Some options can only be made in YAML, but an explanation can be found in the -[template yaml here](../../../static/generated/configs/Rogue%20Legacy.yaml). - -## What does randomization do to this game? - -Rogue Legacy Randomizer takes all the classes, skills, runes, and blueprints and spreads them out into chests, the manor -upgrade screen, bosses, and some special individual locations. The goal is to become powerful enough to defeat the four -zone bosses and then defeat The Fountain. - -## What items and locations get shuffled? -All the skill upgrades, class upgrades, runes packs, and equipment packs are shuffled in the manor upgrade screen, diary -checks, chests and fairy chests, and boss rewards. Skill upgrades are also grouped in packs of 5 to make the finding of -stats less of a chore. Runes and Equipment are also grouped together. - -Some additional locations that can contain items are the Jukebox, the Portraits, and the mini-game rewards. - -## 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. It is possible to choose to limit -certain items to your own world. -## When the player receives an item, what happens? - -When the player receives an item, your character will hold the item above their head and display it to the world. It's -good for business! - -## What do I do if I encounter a bug with the game? - -Please reach out to Phar#4444 on Discord or you can drop a bug report on the -[GitHub page for Rogue Legacy Randomizer](https://github.com/ThePhar/RogueLegacyRandomizer/issues/new?assignees=&labels=bug&template=report-an-issue---.md&title=%5BIssue%5D). diff --git a/worlds/rogue_legacy/docs/rogue-legacy_en.md b/worlds/rogue_legacy/docs/rogue-legacy_en.md deleted file mode 100644 index fc9f692017..0000000000 --- a/worlds/rogue_legacy/docs/rogue-legacy_en.md +++ /dev/null @@ -1,35 +0,0 @@ -# Rogue Legacy Randomizer Setup Guide - -## Required Software - -- Rogue Legacy Randomizer from the - [Rogue Legacy Randomizer Releases Page](https://github.com/ThePhar/RogueLegacyRandomizer/releases) - -## Recommended Installation Instructions - -Please read the README file on the -[Rogue Legacy Randomizer GitHub](https://github.com/ThePhar/RogueLegacyRandomizer/blob/master/README.md) page for -up-to-date installation instructions. - -## Configuring your YAML file - -### What is a YAML file and why do I need one? - -Your YAML file contains a set of configuration options which provide the generator with information about how it should -generate your game. Each player of a multiworld will provide their own YAML file. This setup allows each player to enjoy -an experience customized for their taste, and different players in the same multiworld can all have different options. - -### Where do I get a YAML file? - -you can customize your options by visiting the [Rogue Legacy Options Page](/games/Rogue%20Legacy/player-options). - -### Connect to the MultiServer - -Once in game, press the start button and the AP connection screen should appear. You will fill out the hostname, port, -slot name, and password (if applicable). You should only need to fill out hostname, port, and password if the server -provides an alternative one to the default values. - -### Play the game - -Once you have entered the required values, you go to Connect and then select Confirm on the "Ready to Start" screen. Now -you're off to start your legacy! diff --git a/worlds/rogue_legacy/test/TestUnique.py b/worlds/rogue_legacy/test/TestUnique.py deleted file mode 100644 index 1ae9968d55..0000000000 --- a/worlds/rogue_legacy/test/TestUnique.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Dict - -from . import RLTestBase -from ..Items import item_table -from ..Locations import location_table - - -class UniqueTest(RLTestBase): - @staticmethod - def test_item_ids_are_all_unique(): - item_ids: Dict[int, str] = {} - for name, data in item_table.items(): - assert data.code not in item_ids.keys(), f"'{name}': {data.code}, is not unique. " \ - f"'{item_ids[data.code]}' also has this identifier." - item_ids[data.code] = name - - @staticmethod - def test_location_ids_are_all_unique(): - location_ids: Dict[int, str] = {} - for name, data in location_table.items(): - assert data.code not in location_ids.keys(), f"'{name}': {data.code}, is not unique. " \ - f"'{location_ids[data.code]}' also has this identifier." - location_ids[data.code] = name diff --git a/worlds/rogue_legacy/test/__init__.py b/worlds/rogue_legacy/test/__init__.py deleted file mode 100644 index 3346476ba6..0000000000 --- a/worlds/rogue_legacy/test/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from test.bases import WorldTestBase - - -class RLTestBase(WorldTestBase): - game = "Rogue Legacy" diff --git a/worlds/sa2b/GateBosses.py b/worlds/sa2b/GateBosses.py index 9e1a81bae9..02e089359b 100644 --- a/worlds/sa2b/GateBosses.py +++ b/worlds/sa2b/GateBosses.py @@ -1,6 +1,7 @@ import typing from BaseClasses import MultiWorld +from Options import OptionError from worlds.AutoWorld import World from .Names import LocationName @@ -99,8 +100,9 @@ def get_gate_bosses(world: World): pass if boss in plando_bosses: - # TODO: Raise error here. Duplicates not allowed - pass + raise OptionError(f"Invalid input for option `plando_bosses`: " + f"No Duplicate Bosses permitted ({boss}) - for " + f"{world.player_name}") plando_bosses[boss_num] = boss @@ -108,13 +110,14 @@ def get_gate_bosses(world: World): available_bosses.remove(boss) for x in range(world.options.number_of_level_gates): - if ("king boom boo" not in selected_bosses) and ("king boom boo" not in available_bosses) and ((x + 1) / world.options.number_of_level_gates) > 0.5: - available_bosses.extend(gate_bosses_with_requirements_table) + if (10 not in selected_bosses) and (king_boom_boo not in available_bosses) and ((x + 1) / world.options.number_of_level_gates) > 0.5: + available_bosses.extend(gate_bosses_with_requirements_table.keys()) world.random.shuffle(available_bosses) chosen_boss = available_bosses[0] if plando_bosses[x] != "None": - available_bosses.append(plando_bosses[x]) + if plando_bosses[x] not in available_bosses: + available_bosses.append(plando_bosses[x]) chosen_boss = plando_bosses[x] selected_bosses.append(all_gate_bosses_table[chosen_boss]) diff --git a/worlds/sa2b/Rules.py b/worlds/sa2b/Rules.py index 9019a5b033..a7ea9becb1 100644 --- a/worlds/sa2b/Rules.py +++ b/worlds/sa2b/Rules.py @@ -324,7 +324,8 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla add_rule_safe(multiworld, LocationName.iron_gate_5, player, lambda state: state.has(ItemName.eggman_large_cannon, player)) add_rule_safe(multiworld, LocationName.dry_lagoon_5, player, - lambda state: state.has(ItemName.rouge_treasure_scope, player)) + lambda state: state.has(ItemName.rouge_pick_nails, player) and + state.has(ItemName.rouge_treasure_scope, player)) add_rule_safe(multiworld, LocationName.sand_ocean_5, player, lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule_safe(multiworld, LocationName.egg_quarters_5, player, @@ -407,8 +408,7 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla lambda state: state.has(ItemName.sonic_bounce_bracelet, player)) add_rule(multiworld.get_location(LocationName.cosmic_wall_chao_1, player), - lambda state: state.has(ItemName.eggman_mystic_melody, player) and - state.has(ItemName.eggman_jet_engine, player)) + lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.cannon_core_chao_1, player), lambda state: state.has(ItemName.tails_booster, player) and @@ -1402,8 +1402,6 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla state.has(ItemName.eggman_large_cannon, player))) add_rule(multiworld.get_location(LocationName.dry_lagoon_lifebox_2, player), lambda state: state.has(ItemName.rouge_treasure_scope, player)) - add_rule(multiworld.get_location(LocationName.sand_ocean_lifebox_2, player), - lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.egg_quarters_lifebox_2, player), lambda state: (state.has(ItemName.rouge_mystic_melody, player) and state.has(ItemName.rouge_treasure_scope, player))) @@ -1724,6 +1722,9 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.white_jungle_itembox_8, player), lambda state: state.has(ItemName.shadow_air_shoes, player)) + add_rule(multiworld.get_location(LocationName.sky_rail_itembox_8, player), + lambda state: (state.has(ItemName.shadow_air_shoes, player) and + state.has(ItemName.shadow_mystic_melody, player))) add_rule(multiworld.get_location(LocationName.mad_space_itembox_8, player), lambda state: state.has(ItemName.rouge_iron_boots, player)) add_rule(multiworld.get_location(LocationName.cosmic_wall_itembox_8, player), @@ -2308,8 +2309,7 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: lambda state: state.has(ItemName.tails_booster, player)) add_rule(multiworld.get_location(LocationName.cosmic_wall_chao_1, player), - lambda state: state.has(ItemName.eggman_mystic_melody, player) and - state.has(ItemName.eggman_jet_engine, player)) + lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.cannon_core_chao_1, player), lambda state: state.has(ItemName.tails_booster, player) and @@ -2980,8 +2980,6 @@ def set_mission_upgrade_rules_hard(multiworld: MultiWorld, world: World, player: state.has(ItemName.eggman_jet_engine, player))) add_rule(multiworld.get_location(LocationName.dry_lagoon_lifebox_2, player), lambda state: state.has(ItemName.rouge_treasure_scope, player)) - add_rule(multiworld.get_location(LocationName.sand_ocean_lifebox_2, player), - lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.egg_quarters_lifebox_2, player), lambda state: (state.has(ItemName.rouge_mystic_melody, player) and state.has(ItemName.rouge_treasure_scope, player))) @@ -3593,8 +3591,7 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe lambda state: state.has(ItemName.tails_booster, player)) add_rule(multiworld.get_location(LocationName.cosmic_wall_chao_1, player), - lambda state: state.has(ItemName.eggman_mystic_melody, player) and - state.has(ItemName.eggman_jet_engine, player)) + lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.cannon_core_chao_1, player), lambda state: state.has(ItemName.eggman_jet_engine, player) and @@ -3643,9 +3640,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe add_rule(multiworld.get_location(LocationName.cosmic_wall_pipe_2, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) - add_rule(multiworld.get_location(LocationName.cannon_core_pipe_2, player), - lambda state: state.has(ItemName.tails_booster, player)) - add_rule(multiworld.get_location(LocationName.prison_lane_pipe_3, player), lambda state: state.has(ItemName.tails_bazooka, player)) add_rule(multiworld.get_location(LocationName.mission_street_pipe_3, player), @@ -3771,10 +3765,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe add_rule(multiworld.get_location(LocationName.cosmic_wall_beetle, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) - add_rule(multiworld.get_location(LocationName.cannon_core_beetle, player), - lambda state: state.has(ItemName.tails_booster, player) and - state.has(ItemName.knuckles_hammer_gloves, player)) - # Animal Upgrade Requirements if world.options.animalsanity: add_rule(multiworld.get_location(LocationName.hidden_base_animal_2, player), @@ -3839,8 +3829,7 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe add_rule(multiworld.get_location(LocationName.weapons_bed_animal_8, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.security_hall_animal_8, player), - lambda state: state.has(ItemName.rouge_pick_nails, player) and - state.has(ItemName.rouge_iron_boots, player)) + lambda state: state.has(ItemName.rouge_iron_boots, player)) add_rule(multiworld.get_location(LocationName.cosmic_wall_animal_8, player), lambda state: state.has(ItemName.eggman_jet_engine, player)) @@ -3976,8 +3965,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe state.has(ItemName.tails_bazooka, player)) add_rule(multiworld.get_location(LocationName.crazy_gadget_animal_16, player), lambda state: state.has(ItemName.sonic_flame_ring, player)) - add_rule(multiworld.get_location(LocationName.final_rush_animal_16, player), - lambda state: state.has(ItemName.sonic_bounce_bracelet, player)) add_rule(multiworld.get_location(LocationName.final_chase_animal_17, player), lambda state: state.has(ItemName.shadow_flame_ring, player)) @@ -4035,8 +4022,6 @@ def set_mission_upgrade_rules_expert(multiworld: MultiWorld, world: World, playe lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.dry_lagoon_lifebox_2, player), lambda state: state.has(ItemName.rouge_treasure_scope, player)) - add_rule(multiworld.get_location(LocationName.sand_ocean_lifebox_2, player), - lambda state: state.has(ItemName.eggman_jet_engine, player)) add_rule(multiworld.get_location(LocationName.egg_quarters_lifebox_2, player), lambda state: state.has(ItemName.rouge_treasure_scope, player)) diff --git a/worlds/saving_princess/__init__.py b/worlds/saving_princess/__init__.py index 4109f356fd..f731012abc 100644 --- a/worlds/saving_princess/__init__.py +++ b/worlds/saving_princess/__init__.py @@ -12,7 +12,7 @@ from .Constants import * def launch_client(*args: str): from .Client import launch - launch_subprocess(launch(*args), name=CLIENT_NAME) + launch_subprocess(launch, name=CLIENT_NAME, args=args) components.append( diff --git a/worlds/shapez/__init__.py b/worlds/shapez/__init__.py index 2a77ed8c9c..5557e2a96a 100644 --- a/worlds/shapez/__init__.py +++ b/worlds/shapez/__init__.py @@ -1,5 +1,5 @@ import math -from typing import Any, List, Dict, Tuple, Mapping +from typing import Mapping, Any from Options import OptionError from .data.strings import OTHER, ITEMS, CATEGORY, LOCATIONS, SLOTDATA, GOALS, OPTIONS @@ -123,23 +123,23 @@ class ShapezWorld(World): # Defining instance attributes for each shapez world # These are set to default values that should fail unit tests if not replaced with correct values self.location_count: int = 0 - self.level_logic: List[str] = [] - self.upgrade_logic: List[str] = [] + self.level_logic: list[str] = [] + self.upgrade_logic: list[str] = [] self.level_logic_type: str = "" self.upgrade_logic_type: str = "" - self.random_logic_phase_length: List[int] = [] - self.category_random_logic_amounts: Dict[str, int] = {} + self.random_logic_phase_length: list[int] = [] + self.category_random_logic_amounts: dict[str, int] = {} self.maxlevel: int = 0 self.finaltier: int = 0 - self.included_locations: Dict[str, Tuple[str, LocationProgressType]] = {} + self.included_locations: dict[str, tuple[str, LocationProgressType]] = {} self.client_seed: int = 0 - self.shapesanity_names: List[str] = [] + self.shapesanity_names: list[str] = [] self.upgrade_traps_allowed: bool = False # Universal Tracker support self.ut_active: bool = False - self.passthrough: Dict[str, any] = {} - self.location_id_to_alias: Dict[int, str] = {} + self.passthrough: dict[str, Any] = {} + self.location_id_to_alias: dict[int, str] = {} @classmethod def stage_generate_early(cls, multiworld: MultiWorld) -> None: @@ -315,7 +315,7 @@ class ShapezWorld(World): def create_items(self) -> None: # Include guaranteed items (game mechanic unlocks and 7x4 big upgrades) - included_items: List[Item] = ([self.create_item(name) for name in buildings_processing.keys()] + included_items: list[Item] = ([self.create_item(name) for name in buildings_processing.keys()] + [self.create_item(name) for name in buildings_routing.keys()] + [self.create_item(name) for name in buildings_other.keys()] + [self.create_item(name) for name in buildings_top_row.keys()] @@ -412,6 +412,6 @@ class ShapezWorld(World): **logic_type_cat_random_data, SLOTDATA.seed: self.client_seed, SLOTDATA.shapesanity: self.shapesanity_names} - def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]: + def interpret_slot_data(self, slot_data: dict[str, Any]) -> dict[str, Any]: """Helper function for Universal Tracker""" return slot_data diff --git a/worlds/shapez/common/options.py b/worlds/shapez/common/options.py index aa66ced032..8a55448cd4 100644 --- a/worlds/shapez/common/options.py +++ b/worlds/shapez/common/options.py @@ -1,5 +1,5 @@ import random -import typing +from typing import cast, Any from Options import FreeText, NumericOption @@ -47,7 +47,7 @@ class FloatRangeText(FreeText, NumericOption): raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}") @classmethod - def from_text(cls, text: str) -> typing.Any: + def from_text(cls, text: str) -> Any: return cls(text) @classmethod @@ -99,31 +99,31 @@ class FloatRangeText(FreeText, NumericOption): def get_option_name(cls, value: float) -> str: return str(value) - def __eq__(self, other: typing.Any): + def __eq__(self, other: Any): if isinstance(other, NumericOption): return self.value == other.value else: - return typing.cast(bool, self.value == other) + return cast(bool, self.value == other) - def __lt__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __lt__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value < other.value else: return self.value < other - def __le__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __le__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value <= other.value else: return self.value <= other - def __gt__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __gt__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value > other.value else: return self.value > other - def __ge__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __ge__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value >= other.value else: @@ -132,59 +132,59 @@ class FloatRangeText(FreeText, NumericOption): def __int__(self) -> int: return int(self.value) - def __and__(self, other: typing.Any) -> int: + def __and__(self, other: Any) -> int: raise TypeError("& operator not supported for float values") - def __floordiv__(self, other: typing.Any) -> int: + def __floordiv__(self, other: Any) -> int: return int(self.value // float(other)) def __invert__(self) -> int: raise TypeError("~ operator not supported for float values") - def __lshift__(self, other: typing.Any) -> int: + def __lshift__(self, other: Any) -> int: raise TypeError("<< operator not supported for float values") - def __mod__(self, other: typing.Any) -> float: + def __mod__(self, other: Any) -> float: return self.value % float(other) def __neg__(self) -> float: return -self.value - def __or__(self, other: typing.Any) -> int: + def __or__(self, other: Any) -> int: raise TypeError("| operator not supported for float values") def __pos__(self) -> float: return +self.value - def __rand__(self, other: typing.Any) -> int: + def __rand__(self, other: Any) -> int: raise TypeError("& operator not supported for float values") - def __rfloordiv__(self, other: typing.Any) -> int: + def __rfloordiv__(self, other: Any) -> int: return int(float(other) // self.value) - def __rlshift__(self, other: typing.Any) -> int: + def __rlshift__(self, other: Any) -> int: raise TypeError("<< operator not supported for float values") - def __rmod__(self, other: typing.Any) -> float: + def __rmod__(self, other: Any) -> float: return float(other) % self.value - def __ror__(self, other: typing.Any) -> int: + def __ror__(self, other: Any) -> int: raise TypeError("| operator not supported for float values") - def __round__(self, ndigits: typing.Optional[int] = None) -> float: + def __round__(self, ndigits: int | None = None) -> float: return round(self.value, ndigits) - def __rpow__(self, base: typing.Any) -> typing.Any: + def __rpow__(self, base: Any) -> Any: return base ** self.value - def __rrshift__(self, other: typing.Any) -> int: + def __rrshift__(self, other: Any) -> int: raise TypeError(">> operator not supported for float values") - def __rshift__(self, other: typing.Any) -> int: + def __rshift__(self, other: Any) -> int: raise TypeError(">> operator not supported for float values") - def __rxor__(self, other: typing.Any) -> int: + def __rxor__(self, other: Any) -> int: raise TypeError("^ operator not supported for float values") - def __xor__(self, other: typing.Any) -> int: + def __xor__(self, other: Any) -> int: raise TypeError("^ operator not supported for float values") diff --git a/worlds/shapez/data/generate.py b/worlds/shapez/data/generate.py index 27d74e865d..86b660ef5b 100644 --- a/worlds/shapez/data/generate.py +++ b/worlds/shapez/data/generate.py @@ -1,14 +1,13 @@ import itertools import time -from typing import Dict, List from worlds.shapez.data.strings import SHAPESANITY, REGIONS -shapesanity_simple: Dict[str, str] = {} -shapesanity_1_4: Dict[str, str] = {} -shapesanity_two_sided: Dict[str, str] = {} -shapesanity_three_parts: Dict[str, str] = {} -shapesanity_four_parts: Dict[str, str] = {} +shapesanity_simple: dict[str, str] = {} +shapesanity_1_4: dict[str, str] = {} +shapesanity_two_sided: dict[str, str] = {} +shapesanity_three_parts: dict[str, str] = {} +shapesanity_four_parts: dict[str, str] = {} subshape_names = [SHAPESANITY.circle, SHAPESANITY.square, SHAPESANITY.star, SHAPESANITY.windmill] color_names = [SHAPESANITY.red, SHAPESANITY.blue, SHAPESANITY.green, SHAPESANITY.yellow, SHAPESANITY.purple, SHAPESANITY.cyan, SHAPESANITY.white, SHAPESANITY.uncolored] @@ -16,7 +15,7 @@ short_subshapes = ["C", "R", "S", "W"] short_colors = ["b", "c", "g", "p", "r", "u", "w", "y"] -def color_to_needed_building(color_list: List[str]) -> str: +def color_to_needed_building(color_list: list[str]) -> str: for next_color in color_list: if next_color in [SHAPESANITY.yellow, SHAPESANITY.purple, SHAPESANITY.cyan, SHAPESANITY.white, "y", "p", "c", "w"]: diff --git a/worlds/shapez/docs/datapackage_settings_de.md b/worlds/shapez/docs/datapackage_settings_de.md index ae375f3e3c..a6c1b35cb8 100644 --- a/worlds/shapez/docs/datapackage_settings_de.md +++ b/worlds/shapez/docs/datapackage_settings_de.md @@ -4,7 +4,7 @@ Die Maximalwerte von `goal_amount` und `shapesanity_amount` sind fest eingebaute Einstellungen, die das Datenpaket des Spiels beeinflussen. Sie sind in einer Datei names `options.json` innerhalb der APWorld festgelegt. Durch das Ändern -dieser Werte erschaffst du eine custom APWorld, die nur auf deinem PC existiert. +dieser Werte erschaffst du eine custom Version der APWorld, die nur auf deinem PC existiert. ## Wie du die Datenpaket-Einstellungen änderst @@ -18,17 +18,18 @@ ordnungsgemäß befolgt wird. Anwendung auf eigene Gefahr. - `max_shapesanity` kann nicht weniger als `4` sein, da dies die benötigte Mindestanzahl zum Verhindern von FillErrors ist. - `max_shapesanity` kann auch nicht mehr als `75800` sein, da dies die maximale Anzahl an möglichen Shapesanity-Namen - ist. Ansonsten könnte die Generierung der Multiworld fehlschlagen. + ist. Das Generieren der Multiworld wird fehlschlagen, falls die `shapesanity_amount`-Option auf einen höheren Wert + gesetzt wird. - `max_levels_and_upgrades` kann nicht weniger als `27` sein, da dies die Mindestanzahl für das `mam`-Ziel ist. 5. Schließe die Zip-Datei und benenne sie zurück zu `shapez.apworld`. ## Warum muss ich das ganze selbst machen? Alle Spiele in Archipelago müssen eine Liste aller möglichen Locations **unabhängig der Spieler-Optionen** -bereitstellen. Diese Listen aller in einer Multiworld inkludierten Spiele werden in den Daten der Multiworld gespeichert +bereitstellen. Diese Listen aller in einer Multiworld inkludierten Spiele werden in den Daten der Multiworld gespeichert und an alle verbundenen Clients gesendet. Je mehr mögliche Locations, desto größer das Datenpaket. Und mit ~80000 möglichen Locations hatte shapez zu einem gewissen Zeitpunkt ein (von der Datenmenge her) größeres Datenpaket als alle -supporteten Spiele zusammen. Um also diese Datenmenge zu reduzieren wurden die ausgeschriebenen +Core-verifizierten Spiele zusammen. Um also diese Datenmenge zu reduzieren, wurden die ausgeschriebenen Shapesanity-Locations-Namen (`Shapesanity Uncolored Circle`, `Shapesanity Blue Rectangle`, ...) durch standardisierte Namen (`Shapesanity 1`, `Shapesanity 2`, ...) ersetzt. Durch das Ändern dieser Maximalwerte, und damit das Erstellen einer custom APWorld, kannst du die Anzahl der möglichen Locations erhöhen, wirst aber auch gleichzeitig das Datenpaket diff --git a/worlds/shapez/docs/datapackage_settings_en.md b/worlds/shapez/docs/datapackage_settings_en.md index fd0ed1673d..64f39abf2e 100644 --- a/worlds/shapez/docs/datapackage_settings_en.md +++ b/worlds/shapez/docs/datapackage_settings_en.md @@ -1,14 +1,14 @@ -# Guide to change maximum locations in shapez +# Guide to change the maximum amount of locations in shapez ## Where do I find the settings to increase/decrease the amount of possible locations? -The maximum values of the `goal_amount` and `shapesanity_amount` are hardcoded settings that affect the datapackage. -They are stored in a file called `options.json` inside the apworld. By changing them, you will create a custom apworld -on your local machine. +The maximum values of the `goal_amount` and `shapesanity_amount` options are hardcoded settings that affect the +datapackage. They are stored in a file called `options.json` inside the apworld. By changing them, you will create a +custom version on your local machine. -## How to change datapackage options +## How to change datapackage settings -This tutorial is for advanced users and can result in the software not working properly, if not read carefully. +This tutorial is intended for advanced users and can result in the software not working properly, if not read carefully. Proceed at your own risk. 1. Go to `/lib/worlds`. @@ -17,17 +17,17 @@ Proceed at your own risk. 4. Edit the values in this file to your desire and save the file. - `max_shapesanity` cannot be lower than `4`, as this is the minimum amount to prevent FillErrors. - `max_shapesanity` also cannot be higher than `75800`, as this is the maximum amount of possible shapesanity names. - Else the multiworld generation might fail. + Multiworld generation will fail if the `shapesanity_amount` options is set to a higher value. - `max_levels_and_upgrades` cannot be lower than `27`, as this is the minimum amount for the `mam` goal to properly work. -5. Close the zip and rename it back to `shapez.apworld`. +5. Close the zip file and rename it back to `shapez.apworld`. ## Why do I have to do this manually? For every game in Archipelago, there must be a list of all possible locations, **regardless of player options**. When -generating a multiworld, a list of all locations of all included games will be saved in the multiworld data and sent to -all clients. The higher the amount of possible locations, the bigger the datapackage. And having ~80000 possible -locations at one point made the datapackage for shapez bigger than all other supported games combined. So to reduce the -datapackage of shapez, the locations for shapesanity are named `Shapesanity 1`, `Shapesanity 2` etc. instead of their -actual names. By creating a custom apworld, you can increase the amount of possible locations, but you will also -increase the size of the datapackage at the same time. +generating a multiworld, a list of all locations of all included games will be saved in the multiworld's data and sent +to all clients. The higher the amount of possible locations, the bigger the datapackage. And having ~80000 possible +locations at one point made the datapackage for shapez bigger than all other core-verified games combined. So, to reduce +the datapackage size of shapez, the locations for shapesanity are named `Shapesanity 1`, `Shapesanity 2` etc. instead of +their actual names. By creating a custom version of the apworld, you can increase the amount of possible locations, but +you will also increase the size of the datapackage at the same time. diff --git a/worlds/shapez/docs/de_shapez.md b/worlds/shapez/docs/de_shapez.md index 5ef8f13f79..494edca210 100644 --- a/worlds/shapez/docs/de_shapez.md +++ b/worlds/shapez/docs/de_shapez.md @@ -19,25 +19,27 @@ Zusätzlich gibt es zu diesem Spiel "Datenpaket-Einstellungen", die du nach Alle Belohnungen aus den Tutorial-Level (das Freischalten von Gebäuden und Spielmechaniken) und Verbesserungen durch Upgrades werden dem Itempool der Multiworld hinzugefügt. Außerdem werden, wenn so in den Spieler-Optionen festgelegt, -die Bedingungen zum Abschließen eines Levels und zum Kaufen der Upgrades randomisiert. +die Bedingungen zum Abschließen eines Levels und zum Kaufen der Upgrades randomisiert und die Reihenfolge der Gebäude +in deinen Toolbars (Haupt- und Kabelebene) gemischt. ## Was ist das Ziel von shapez in Archipelago? -Da das Spiel eigentlich kein konkretes Ziel (nach dem Tutorial) hat, kann man sich zwischen (momentan) 4 verschiedenen -Zielen entscheiden: +Da das Spiel eigentlich kein konkretes Ziel, welches das Ende des Spiels bedeuten würde, hat, kann man sich zwischen +(aktuell) 4 verschiedenen Zielen entscheiden: 1. Vanilla: Schließe Level 26 ab (eigentlich das Ende des Tutorials). 2. MAM: Schließe ein bestimmtes Level nach Level 26 ab, das zuvor in den Spieler-Optionen festgelegt wurde. Es ist empfohlen, eine Maschine zu bauen, die alles automatisch herstellt ("Make-Anything-Machine", kurz MAM). -3. Even Fasterer: Kaufe alle Upgrades bis zu einer in den Spieler-Optionen festgelegten Stufe (nach Stufe 8). +3. Even Fasterer: Kaufe alle Upgrades bis zu einer in den Spieler-Optionen festgelegten Stufe (nach Stufe VIII (8)). 4. Efficiency III: Liefere 256 Blaupausen-Formen pro Sekunde ins Zentrum. ## Welche Items können in den Welten anderer Spieler erscheinen? -- Freischalten verschiedener Gebäude +- Gebäude - Blaupausen freischalten -- Große Upgrades (addiert 1 zum Geschwindigkeitsmultiplikator) -- Kleine Upgrades (addiert 0.1 zum Geschwindigkeitsmultiplikator) -- Andere ungewöhnliche Upgrades (optional) +- Upgrades + - Große Upgrades (addiert 1 zum Geschwindigkeitsmultiplikator) + - Kleine Upgrades (addiert 0.1 zum Geschwindigkeitsmultiplikator) + - Andere ungewöhnliche (auch negative) Upgrades (optional) - Verschiedene Bündel, die bestimmte Formen enthalten - Fallen, die bestimmte Formen aus dem Zentrum dränieren (ja, das Wort gibt es) - Fallen, die zufällige Gebäude oder andere Spielmechaniken betreffen @@ -45,7 +47,7 @@ empfohlen, eine Maschine zu bauen, die alles automatisch herstellt ("Make-Anythi ## Was ist eine Location / ein Check? - Level (minimum 1-25, bis zu 499 je nach Spieler-Optionen, mit zusätzlichen Checks für Level 1 und 20) -- Upgrades (minimum Stufen II-VIII (2-8), bis zu D (500) je nach Spieler-Optionen) +- Upgrades (minimum Stufen II-VIII (2-8), bis zu D (500), je nach Spieler-Optionen) - Bestimmte Formen mindestens einmal ins Zentrum liefern ("Shapesanity", bis zu 1000 zufällig gewählte Definitionen) - Errungenschaften (bis zu 45) @@ -57,7 +59,7 @@ Ein Pop-Up erscheint, das das/die erhaltene(n) Item(s) und eventuell weitere Inf Hier ist ein Spicker für die Englischarbeit (bloß nicht dem Lehrer zeigen): -![image](https://raw.githubusercontent.com/BlastSlimey/Archipelago/refs/heads/main/worlds/shapez/docs/shapesanity_full.png) +![image](/static/generated/docs/shapez/shapesanity_full.png) ## Kann ich auch weitere Mods neben dem AP Client installieren? diff --git a/worlds/shapez/docs/en_shapez.md b/worlds/shapez/docs/en_shapez.md index 4af398c5f1..56c0387258 100644 --- a/worlds/shapez/docs/en_shapez.md +++ b/worlds/shapez/docs/en_shapez.md @@ -4,9 +4,9 @@ shapez is an automation game about cutting, rotating, stacking, and painting shapes, that you extract from randomly generated patches on an infinite canvas, and sending them to the hub to complete levels. The "tutorial", where you -unlock a new building or game mechanic (almost) each level, lasts until level 26, where you unlock freeplay with -infinitely more levels, that require a new, randomly generated shape. Alongside the levels, you can unlock upgrades, -that make your buildings work faster. +unlock a new building or game mechanic (almost) each level, lasts until level 26, which unlocks freeplay with +infinitely more levels, that each require a new, randomly generated shape. Alongside the levels, you can unlock +upgrades, that make your buildings work faster. ## Where is the options page? @@ -17,29 +17,30 @@ There are also some advanced "datapackage settings" that can be changed by follo ## What does randomization do to this game? -Buildings and gameplay mechanics, that you normally unlock by completing a level, and upgrade improvements are put -into the item pool of the multiworld. Also, if enabled, the requirements for completing a level or buying an upgrade are -randomized. +Buildings and gameplay mechanics, which you normally unlock by completing a level, and upgrade improvements are put +into the item pool of the multiworld. You can also randomize the requirements for completing a level or buying an +upgrade and shuffle the order of building in your toolbars (main and wires layer). ## What is the goal of shapez in Archipelago? -As the game has no actual goal where the game ends, there are (currently) 4 different goals you can choose from in the -player options: +As the game has no actual goal that would represent the end of the game, there are (currently) 4 different goals you +can choose from in the player options: 1. Vanilla: Complete level 26 (the end of the tutorial). 2. MAM: Complete a player-specified level after level 26. It's recommended to build a Make-Anything-Machine (MAM). -3. Even Fasterer: Upgrade everything to a player-specified tier after tier 8. +3. Even Fasterer: Upgrade everything to a player-specified tier after tier VIII (8). 4. Efficiency III: Deliver 256 blueprint shapes per second to the hub. ## Which items can be in another player's world? -- Unlock different buildings -- Unlock blueprints -- Big upgrade improvements (adds 1 to the multiplier) -- Small upgrade improvements (adds .1 to the multiplier) -- Other unusual upgrade improvements (optional) +- Buildings +- Unlocking blueprints +- Upgrade improvements + - Big improvements, adding 1 to the multiplier + - Small improvements, adding 0.1 to the multiplier + - Optional: Other, rather unusual and even bad, improvements - Different shapes bundles - Inventory draining traps -- Different traps afflicting random buildings and game mechanics +- Different traps affecting random buildings and game mechanics ## What is considered a location check? @@ -56,10 +57,9 @@ A pop-up will show, which item(s) were received, with additional information on Here's a cheat sheet: -![image](https://raw.githubusercontent.com/BlastSlimey/Archipelago/refs/heads/main/worlds/shapez/docs/shapesanity_full.png) +![image](/static/generated/docs/shapez/shapesanity_full.png) ## Can I use other mods alongside the AP client? At the moment, compatibility with other mods is not supported, but not forbidden. Gameplay altering mods will most -likely crash the game or disable loading the afflicted mods, while QoL mods might work without problems. Try at your own -risk. +likely break the game in some way, while small QoL mods might work without problems. Try at your own risk. diff --git a/worlds/shapez/docs/setup_de.md b/worlds/shapez/docs/setup_de.md index 1b927f3790..a2eb92dfa1 100644 --- a/worlds/shapez/docs/setup_de.md +++ b/worlds/shapez/docs/setup_de.md @@ -16,9 +16,10 @@ - Archipelago von der [Archipelago-Release-Seite](https://github.com/ArchipelagoMW/Archipelago/releases) * (Für den Text-Client) - * (Alternativ kannst du auch die eingebaute Konsole (nur lesbar) nutzen, indem du beim Starten des Spiels den - `-dev`-Parameter verwendest) -- Universal Tracker (schau im `#future-game-design`-Thread für UT auf dem Discord-Server nach der aktuellen Anleitung) + * (Alternativ kannst du auch die eingebaute Konsole nutzen, indem du das Spiel mit dem `-dev`-Parameter + startest und jede Nachricht als `AP.sendAPMessage(""")` schreibst) +- Universal Tracker (schau im Kanal von UT auf dem Discord-Server nach der aktuellen Anleitung und für weitere + Informationen) ## Installation diff --git a/worlds/shapez/docs/setup_en.md b/worlds/shapez/docs/setup_en.md index 4c91c16a0b..2036f75d6c 100644 --- a/worlds/shapez/docs/setup_en.md +++ b/worlds/shapez/docs/setup_en.md @@ -16,9 +16,9 @@ - Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) * (Only for the TextClient) - * (If you want, you can use the built-in console as a read-only text client by launching the game - with the `-dev` parameter) -- Universal Tracker (check UT's `#future-game-design` thread in the discord server for instructions) + * (You can alternatively use the built-in console by launching the game with the `-dev` parameter and typing + `AP.sendAPMessage(""")`) +- Universal Tracker (check UT's channel in the discord server for more information and instructions) ## Installation diff --git a/worlds/shapez/items.py b/worlds/shapez/items.py index aef4c03317..2e5816b9fc 100644 --- a/worlds/shapez/items.py +++ b/worlds/shapez/items.py @@ -1,4 +1,4 @@ -from typing import Dict, Callable, Any, List +from typing import Callable, Any from BaseClasses import Item, ItemClassification as IClass from .options import ShapezOptions @@ -37,7 +37,7 @@ def always_trap(options: ShapezOptions) -> IClass: # would be unreasonably complicated and time-consuming. # Some buildings are not needed to complete the game, but are "logically needed" for the "MAM" achievement. -buildings_processing: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_processing: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.cutter: always_progression, ITEMS.cutter_quad: always_progression, ITEMS.rotator: always_progression, @@ -50,7 +50,7 @@ buildings_processing: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.color_mixer: always_progression, } -buildings_routing: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_routing: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.balancer: always_progression, ITEMS.comp_merger: always_progression, ITEMS.comp_splitter: always_progression, @@ -58,12 +58,12 @@ buildings_routing: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.tunnel_tier_ii: is_mam_achievement_included, } -buildings_other: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_other: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trash: always_progression, ITEMS.extractor_chain: always_useful } -buildings_top_row: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_top_row: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.belt_reader: is_mam_achievement_included, ITEMS.storage: is_achievements_included, ITEMS.switch: always_progression, @@ -71,18 +71,18 @@ buildings_top_row: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.display: always_useful } -buildings_wires: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_wires: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.wires: always_progression, ITEMS.const_signal: always_progression, ITEMS.logic_gates: is_mam_achievement_included, ITEMS.virtual_proc: is_mam_achievement_included } -gameplay_unlocks: Dict[str, Callable[[ShapezOptions], IClass]] = { +gameplay_unlocks: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.blueprints: is_achievements_included } -upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { +upgrades: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_big_belt: always_progression, ITEMS.upgrade_big_miner: always_useful, ITEMS.upgrade_big_proc: always_useful, @@ -93,7 +93,7 @@ upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_small_paint: always_filler } -whacky_upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { +whacky_upgrades: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_gigantic_belt: always_progression, ITEMS.upgrade_gigantic_miner: always_useful, ITEMS.upgrade_gigantic_proc: always_useful, @@ -106,7 +106,7 @@ whacky_upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_small_random: always_filler, } -whacky_upgrade_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { +whacky_upgrade_traps: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_upgrade_belt: always_trap, ITEMS.trap_upgrade_miner: always_trap, ITEMS.trap_upgrade_proc: always_trap, @@ -117,13 +117,13 @@ whacky_upgrade_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_upgrade_demonic_paint: always_trap, } -bundles: Dict[str, Callable[[ShapezOptions], IClass]] = { +bundles: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.bundle_blueprint: always_filler, ITEMS.bundle_level: always_filler, ITEMS.bundle_upgrade: always_filler } -standard_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { +standard_traps: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_locked: always_trap, ITEMS.trap_throttled: always_trap, ITEMS.trap_malfunction: always_trap, @@ -131,22 +131,22 @@ standard_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_clear_belts: always_trap, } -random_draining_trap: Dict[str, Callable[[ShapezOptions], IClass]] = { +random_draining_trap: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_draining_inv: always_trap } -split_draining_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { +split_draining_traps: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_draining_blueprint: always_trap, ITEMS.trap_draining_level: always_trap, ITEMS.trap_draining_upgrade: always_trap } -belt_and_extractor: Dict[str, Callable[[ShapezOptions], IClass]] = { +belt_and_extractor: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.belt: always_progression, ITEMS.extractor: always_progression } -item_table: Dict[str, Callable[[ShapezOptions], IClass]] = { +item_table: dict[str, Callable[[ShapezOptions], IClass]] = { **buildings_processing, **buildings_routing, **buildings_other, @@ -205,10 +205,10 @@ def trap(random: float, split_draining: bool, whacky_allowed: bool) -> str: return random_choice_nested(random, pool) -def random_choice_nested(random: float, nested: List[Any]) -> Any: +def random_choice_nested(random: float, nested: list[Any]) -> Any: """Helper function for getting a random element from a nested list.""" current: Any = nested - while isinstance(current, List): + while isinstance(current, list): index_float = random*len(current) current = current[int(index_float)] random = index_float-int(index_float) diff --git a/worlds/shapez/locations.py b/worlds/shapez/locations.py index 6d069afaa8..f68ca1ebf5 100644 --- a/worlds/shapez/locations.py +++ b/worlds/shapez/locations.py @@ -1,5 +1,5 @@ from random import Random -from typing import List, Tuple, Dict, Optional, Callable +from typing import Callable from BaseClasses import Location, LocationProgressType, Region from .data.strings import CATEGORY, LOCATIONS, REGIONS, OPTIONS, GOALS, OTHER, SHAPESANITY @@ -7,7 +7,7 @@ from .options import max_shapesanity, max_levels_and_upgrades categories = [CATEGORY.belt, CATEGORY.miner, CATEGORY.processors, CATEGORY.painting] -translate: List[Tuple[int, str]] = [ +translate: list[tuple[int, str]] = [ (1000, "M"), (900, "CM"), (500, "D"), @@ -148,17 +148,17 @@ location_description = { # TODO change keys to global strings "windmill.", } -shapesanity_simple: Dict[str, str] = {} -shapesanity_1_4: Dict[str, str] = {} -shapesanity_two_sided: Dict[str, str] = {} -shapesanity_three_parts: Dict[str, str] = {} -shapesanity_four_parts: Dict[str, str] = {} +shapesanity_simple: dict[str, str] = {} +shapesanity_1_4: dict[str, str] = {} +shapesanity_two_sided: dict[str, str] = {} +shapesanity_three_parts: dict[str, str] = {} +shapesanity_four_parts: dict[str, str] = {} -level_locations: List[str] = ([LOCATIONS.level(1, 1), LOCATIONS.level(20, 1), LOCATIONS.level(20, 2)] +level_locations: list[str] = ([LOCATIONS.level(1, 1), LOCATIONS.level(20, 1), LOCATIONS.level(20, 2)] + [LOCATIONS.level(x) for x in range(1, max_levels_and_upgrades)]) -upgrade_locations: List[str] = [LOCATIONS.upgrade(cat, roman(x)) +upgrade_locations: list[str] = [LOCATIONS.upgrade(cat, roman(x)) for cat in categories for x in range(2, max_levels_and_upgrades+1)] -achievement_locations: List[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATIONS.cutter, LOCATIONS.rotater, +achievement_locations: list[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATIONS.cutter, LOCATIONS.rotater, LOCATIONS.wait_they_stack, LOCATIONS.wires, LOCATIONS.storage, LOCATIONS.freedom, LOCATIONS.the_logo, LOCATIONS.to_the_moon, LOCATIONS.its_piling_up, LOCATIONS.use_it_later, LOCATIONS.efficiency_1, LOCATIONS.preparing_to_launch, @@ -172,7 +172,7 @@ achievement_locations: List[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATI LOCATIONS.mam, LOCATIONS.perfectionist, LOCATIONS.next_dimension, LOCATIONS.oops, LOCATIONS.copy_pasta, LOCATIONS.ive_seen_that_before, LOCATIONS.memories, LOCATIONS.i_need_trains, LOCATIONS.a_bit_early, LOCATIONS.gps] -shapesanity_locations: List[str] = [LOCATIONS.shapesanity(x) for x in range(1, max_shapesanity+1)] +shapesanity_locations: list[str] = [LOCATIONS.shapesanity(x) for x in range(1, max_shapesanity+1)] def init_shapesanity_pool() -> None: @@ -186,12 +186,12 @@ def init_shapesanity_pool() -> None: def addlevels(maxlevel: int, logictype: str, - random_logic_phase_length: List[int]) -> Dict[str, Tuple[str, LocationProgressType]]: + random_logic_phase_length: list[int]) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with all level locations based on player options (maxlevel INCLUDED). If shape requirements are not randomized, the logic type is expected to be vanilla.""" # Level 1 is always directly accessible - locations: Dict[str, Tuple[str, LocationProgressType]] \ + locations: dict[str, tuple[str, LocationProgressType]] \ = {LOCATIONS.level(1): (REGIONS.main, LocationProgressType.PRIORITY), LOCATIONS.level(1, 1): (REGIONS.main, LocationProgressType.PRIORITY)} level_regions = [REGIONS.main, REGIONS.levels_1, REGIONS.levels_2, REGIONS.levels_3, @@ -282,11 +282,11 @@ def addlevels(maxlevel: int, logictype: str, def addupgrades(finaltier: int, logictype: str, - category_random_logic_amounts: Dict[str, int]) -> Dict[str, Tuple[str, LocationProgressType]]: + category_random_logic_amounts: dict[str, int]) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with all upgrade locations based on player options (finaltier INCLUDED). If shape requirements are not randomized, give logic type 0.""" - locations: Dict[str, Tuple[str, LocationProgressType]] = {} + locations: dict[str, tuple[str, LocationProgressType]] = {} upgrade_regions = [REGIONS.main, REGIONS.upgrades_1, REGIONS.upgrades_2, REGIONS.upgrades_3, REGIONS.upgrades_4, REGIONS.upgrades_5] @@ -366,13 +366,13 @@ def addupgrades(finaltier: int, logictype: str, def addachievements(excludesoftlock: bool, excludelong: bool, excludeprogressive: bool, - maxlevel: int, upgradelogictype: str, category_random_logic_amounts: Dict[str, int], - goal: str, presentlocations: Dict[str, Tuple[str, LocationProgressType]], + maxlevel: int, upgradelogictype: str, category_random_logic_amounts: dict[str, int], + goal: str, presentlocations: dict[str, tuple[str, LocationProgressType]], add_alias: Callable[[str, str], None], has_upgrade_traps: bool - ) -> Dict[str, Tuple[str, LocationProgressType]]: + ) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with all achievement locations based on player options.""" - locations: Dict[str, Tuple[str, LocationProgressType]] = dict() + locations: dict[str, tuple[str, LocationProgressType]] = dict() upgrade_regions = [REGIONS.main, REGIONS.upgrades_1, REGIONS.upgrades_2, REGIONS.upgrades_3, REGIONS.upgrades_4, REGIONS.upgrades_5] @@ -472,10 +472,10 @@ def addachievements(excludesoftlock: bool, excludelong: bool, excludeprogressive def addshapesanity(amount: int, random: Random, append_shapesanity: Callable[[str], None], - add_alias: Callable[[str, str], None]) -> Dict[str, Tuple[str, LocationProgressType]]: + add_alias: Callable[[str, str], None]) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with a given number of random shapesanity locations.""" - included_shapes: Dict[str, Tuple[str, LocationProgressType]] = {} + included_shapes: dict[str, tuple[str, LocationProgressType]] = {} def f(name: str, region: str, alias: str, progress: LocationProgressType = LocationProgressType.DEFAULT) -> None: included_shapes[name] = (region, progress) @@ -518,11 +518,11 @@ def addshapesanity(amount: int, random: Random, append_shapesanity: Callable[[st return included_shapes -def addshapesanity_ut(shapesanity_names: List[str], add_alias: Callable[[str, str], None] - ) -> Dict[str, Tuple[str, LocationProgressType]]: +def addshapesanity_ut(shapesanity_names: list[str], add_alias: Callable[[str, str], None] + ) -> dict[str, tuple[str, LocationProgressType]]: """Returns the same information as addshapesanity but will add specific values based on a UT rebuild.""" - included_shapes: Dict[str, Tuple[str, LocationProgressType]] = {} + included_shapes: dict[str, tuple[str, LocationProgressType]] = {} for name in shapesanity_names: for options in [shapesanity_simple, shapesanity_1_4, shapesanity_two_sided, shapesanity_three_parts, @@ -540,7 +540,7 @@ def addshapesanity_ut(shapesanity_names: List[str], add_alias: Callable[[str, st class ShapezLocation(Location): game = OTHER.game_name - def __init__(self, player: int, name: str, address: Optional[int], region: Region, + def __init__(self, player: int, name: str, address: int | None, region: Region, progress_type: LocationProgressType): super(ShapezLocation, self).__init__(player, name, address, region) self.progress_type = progress_type diff --git a/worlds/shapez/regions.py b/worlds/shapez/regions.py index c4ca1d0c81..b5835461d8 100644 --- a/worlds/shapez/regions.py +++ b/worlds/shapez/regions.py @@ -1,5 +1,3 @@ -from typing import Dict, Tuple, List - from BaseClasses import Region, MultiWorld, LocationProgressType, ItemClassification, CollectionState from .items import ShapezItem from .locations import ShapezLocation @@ -102,7 +100,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) -> return multiplier >= needed -def has_logic_list_building(state: CollectionState, player: int, buildings: List[str], index: int, +def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int, includeuseful: bool) -> bool: # Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres @@ -126,11 +124,11 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: List def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool, - included_locations: Dict[str, Tuple[str, LocationProgressType]], - location_name_to_id: Dict[str, int], level_logic_buildings: List[str], - upgrade_logic_buildings: List[str], early_useful: str, goal: str) -> List[Region]: + included_locations: dict[str, tuple[str, LocationProgressType]], + location_name_to_id: dict[str, int], level_logic_buildings: list[str], + upgrade_logic_buildings: list[str], early_useful: str, goal: str) -> list[Region]: """Creates and returns a list of all regions with entrances and all locations placed correctly.""" - regions: Dict[str, Region] = {name: Region(name, player, multiworld) for name in all_regions} + regions: dict[str, Region] = {name: Region(name, player, multiworld) for name in all_regions} # Creates ShapezLocations for every included location and puts them into the correct region for name, data in included_locations.items(): diff --git a/worlds/shapez/test/__init__.py b/worlds/shapez/test/__init__.py index 3ab626e639..c8855be960 100644 --- a/worlds/shapez/test/__init__.py +++ b/worlds/shapez/test/__init__.py @@ -1,7 +1,7 @@ from unittest import TestCase from test.bases import WorldTestBase -from .. import options_presets, ShapezWorld +from .. import ShapezWorld from ..data.strings import GOALS, OTHER, ITEMS, LOCATIONS, CATEGORY, OPTIONS, SHAPESANITY from ..options import max_levels_and_upgrades, max_shapesanity @@ -92,17 +92,7 @@ class TestGlobalOptionsImport(TestCase): f"{max_levels_and_upgrades} instead.") -class TestMinimum(ShapezTestBase): - options = options_presets["Minimum checks"] - - -class TestMaximum(ShapezTestBase): - options = options_presets["Maximum checks"] - - -class TestRestrictive(ShapezTestBase): - options = options_presets["Restrictive start"] - +# The following unittests are intended to test all code paths of the generator class TestAllRelevantOptions1(ShapezTestBase): options = { diff --git a/worlds/shivers/__init__.py b/worlds/shivers/__init__.py index 3430a5a02d..dd941b9212 100644 --- a/worlds/shivers/__init__.py +++ b/worlds/shivers/__init__.py @@ -261,13 +261,13 @@ class ShiversWorld(World): data.type == ItemType.POT_DUPLICATE] elif self.options.full_pots == "complete": return [self.create_item(name) for name, data in item_table.items() if - data.type == ItemType.POT_COMPELTE_DUPLICATE] + data.type == ItemType.POT_COMPLETE_DUPLICATE] else: pool = [] pieces = [self.create_item(name) for name, data in item_table.items() if data.type == ItemType.POT_DUPLICATE] complete = [self.create_item(name) for name, data in item_table.items() if - data.type == ItemType.POT_COMPELTE_DUPLICATE] + data.type == ItemType.POT_COMPLETE_DUPLICATE] for i in range(10): if self.pot_completed_list[i] == 0: pool.append(pieces[i]) diff --git a/worlds/sm/Options.py b/worlds/sm/Options.py index 3dad16ad3a..7bce352994 100644 --- a/worlds/sm/Options.py +++ b/worlds/sm/Options.py @@ -1,5 +1,5 @@ import typing -from Options import Choice, PerGameCommonOptions, Range, OptionDict, OptionList, OptionSet, Option, Toggle, DefaultOnToggle +from Options import Choice, PerGameCommonOptions, Range, OptionDict, OptionList, OptionSet, OptionGroup, Toggle, DefaultOnToggle from .variaRandomizer.utils.objectives import _goals from dataclasses import dataclass @@ -8,8 +8,15 @@ class StartItemsRemovesFromPool(Toggle): display_name = "StartItems Removes From Item Pool" class Preset(Choice): - """Choose one of the presets or specify "varia_custom" to use varia_custom_preset option or specify "custom" to use - custom_preset option.""" + """Determines the general difficulty of the item placements by adjusting the list of tricks that logic allows. + - Newbie: New to randomizers, but completed Super Metroid 100% and knows basic techniques (Wall Jump, Shinespark, Mid-air Morph) + - Casual: Occasional rando player. No hell runs or suitless Maridia, some easy to learn tricks in logic. + - Regular: Plays rando regularly. Knows many tricks that open up the game. + - Veteran: Experienced rando player. Harder everything, some tougher tricks in logic. + - Expert: Knows almost all tricks: full suitless Maridia, Lower Norfair hell runs, etc. + - Master: Everything on hardest, all tricks known. + In-depth details on each preset can be found on the VARIA website: https://varia.run/presets + You may also specify "varia_custom" to use varia_custom_preset option, or specify "custom" to use custom_preset option.""" display_name = "Preset" option_newbie = 0 option_casual = 1 @@ -46,7 +53,8 @@ class StartLocation(Choice): default = 1 class DeathLink(Choice): - """When DeathLink is enabled and someone dies, you will die. With survive reserve tanks can save you.""" + """When DeathLink is enabled and someone else with DeathLink dies, you will die. + If "Enable Survive" is selected, reserve tanks can save you.""" display_name = "Death Link" option_disable = 0 option_enable = 1 @@ -56,11 +64,13 @@ class DeathLink(Choice): default = 0 class RemoteItems(Toggle): - """Indicates you get items sent from your own world. This allows coop play of a world.""" - display_name = "Remote Items" + """Items from your own world are sent via the Archipelago server. This allows co-op play of a world and means that + you will not lose items on death or save file loss.""" + display_name = "Remote Items" class MaxDifficulty(Choice): - """Depending on the perceived difficulties of the techniques, bosses, hell runs etc. from the preset, it will + """Maximum difficulty of tricks that are allowed from the seed's Preset. + Depending on the perceived difficulties of the techniques, bosses, hell runs etc. from the preset, it will prevent the Randomizer from placing an item in a location too difficult to reach with the current items.""" display_name = "Maximum Difficulty" option_easy = 0 @@ -73,7 +83,7 @@ class MaxDifficulty(Choice): default = 4 class MorphPlacement(Choice): - """Influences where the Morphing Ball with be placed.""" + """Influences where the Morphing Ball will be placed.""" display_name = "Morph Placement" option_early = 0 option_normal = 1 @@ -85,21 +95,21 @@ class StrictMinors(Toggle): display_name = "Strict Minors" class MissileQty(Range): - """The higher the number the higher the probability of choosing missles when placing a minor.""" + """The higher the number, the higher the probability of choosing Missiles when placing a minor.""" display_name = "Missile Quantity" range_start = 10 range_end = 90 default = 30 class SuperQty(Range): - """The higher the number the higher the probability of choosing super missles when placing a minor.""" + """The higher the number, the higher the probability of choosing Super Missiles when placing a minor.""" display_name = "Super Quantity" range_start = 10 range_end = 90 default = 20 class PowerBombQty(Range): - """The higher the number the higher the probability of choosing power bombs when placing a minor.""" + """The higher the number, the higher the probability of choosing Power Bombs when placing a minor.""" display_name = "Power Bomb Quantity" range_start = 10 range_end = 90 @@ -123,7 +133,13 @@ class EnergyQty(Choice): default = 3 class AreaRandomization(Choice): - """Randomize areas together using bidirectional access portals.""" + """Randomize areas together using bidirectional access portals. + - Off: No change. All rooms are connected the same as in the original game. + - Full: All doors connecting areas will be randomized. "Areas" are roughly determined, but generally are regions + with different tilesets or music. For example, red Brinstar and green/pink Brinstar are different areas, Crocomire + and upper Norfair are different areas, etc. + - Light: Keep the same number of transitions between areas as in vanilla. So Crocomire area will always be connected + to upper Norfair, there'll always be two transitions between Crateria/blue Brinstar and green/pink Brinstar, etc.""" display_name = "Area Randomization" option_off = 0 option_light = 1 @@ -136,13 +152,13 @@ class AreaLayout(Toggle): display_name = "Area Layout" class DoorsColorsRando(Toggle): - """Randomize the color of Red/Green/Yellow doors. Add four new type of doors which require Ice/Wave/Spazer/Plasma - beams to open them.""" + """Randomize the color of Red/Green/Yellow doors. Add four new types of doors which require Ice/Wave/Spazer/Plasma + Beams to open them.""" display_name = "Doors Colors Rando" class AllowGreyDoors(Toggle): """When randomizing the color of Red/Green/Yellow doors, some doors can be randomized to Grey. Grey doors will never - open, you will have to go around them.""" + open; you will have to go around them.""" display_name = "Allow Grey Doors" class BossRandomization(Toggle): @@ -169,7 +185,10 @@ class LayoutPatches(DefaultOnToggle): display_name = "Layout Patches" class VariaTweaks(Toggle): - """Include minor tweaks for the game to behave 'as it should' in a randomizer context""" + """Include minor tweaks for the game to behave 'as it should' in a randomizer context: + - Bomb Torizo always activates after picking up its item and does not require Bomb to activate + - Wrecked Ship item on the Energy Tank Chozo statue is present before defeating Phantoon + - Lower Norfair Chozo statue that lowers the acid toward Gold Torizo does not require Space Jump to activate""" display_name = "Varia Tweaks" class NerfedCharge(Toggle): @@ -179,7 +198,12 @@ class NerfedCharge(Toggle): display_name = "Nerfed Charge" class GravityBehaviour(Choice): - """Modify the heat damage and enemy damage reduction qualities of the Gravity and Varia Suits.""" + """Modify the heat damage and enemy damage reduction qualities of the Gravity and Varia Suits. + - Vanilla: Gravity provides full protection against all environmental damage (heat, spikes, etc.) + - Balanced: Removes Gravity environmental protection. Doubles Varia environmental protection. Enemy damage protection + is vanilla (50% Varia, 75% Gravity). + - Progressive: Gravity provides 50% heat reduction, Varia provides full heat reduction. Each suit adds 50% enemy + and environmental reduction, stacking to 75% reduction if you have both.""" display_name = "Gravity Behaviour" option_Vanilla = 0 option_Balanced = 1 @@ -233,7 +257,7 @@ class RandomMusic(Toggle): class CustomPreset(OptionDict): """ - see https://randommetroidsolver.pythonanywhere.com/presets for detailed info on each preset settings + see https://varia.run/presets for detailed info on each preset settings knows: each skill (know) has a pair [can use, perceived difficulty using one of 1, 5, 10, 25, 50 or 100 each one matching a max_difficulty] settings: hard rooms, hellruns and bosses settings @@ -246,7 +270,7 @@ class CustomPreset(OptionDict): } class VariaCustomPreset(OptionList): - """use an entry from the preset list on https://randommetroidsolver.pythonanywhere.com/presets""" + """use an entry from the preset list on https://varia.run/presets""" display_name = "Varia Custom Preset" default = {} @@ -259,7 +283,7 @@ class EscapeRando(Toggle): During the escape sequence: - All doors are opened - Maridia tube is opened - - The Hyper Beam can destroy Bomb , Power Bomb and Super Missile blocks and open blue/green gates from both sides + - The Hyper Beam can destroy Bomb, Power Bomb and Super Missile blocks and open blue/green gates from both sides - All mini bosses are defeated - All minor enemies are removed to allow you to move faster and remove lag @@ -281,9 +305,9 @@ class RemoveEscapeEnemies(Toggle): class Tourian(Choice): """ Choose endgame Tourian behaviour: - Vanilla: regular vanilla Tourian - Fast: speed up Tourian to skip Metroids, Zebetites, and all cutscenes (including Mother Brain 3 fight). Golden Four statues are replaced by an invincible Gadora until all objectives are completed. - Disabled: skip Tourian entirely, ie. escape sequence is triggered as soon as all objectives are completed. + - Vanilla: regular vanilla Tourian + - Fast: speed up Tourian to skip Metroids, Zebetites, and all cutscenes (including Mother Brain 3 fight). Golden Four statues are replaced by an invincible Gadora until all objectives are completed. + - Disabled: skip Tourian entirely; the escape sequence is triggered as soon as all objectives are completed. """ display_name = "Endgame behavior with Tourian" option_Vanilla = 0 @@ -373,10 +397,71 @@ class RelaxedRoundRobinCF(Toggle): """ display_name = "Relaxed round robin Crystal Flash" +sm_option_groups = [ + OptionGroup("Logic", [ + Preset, + MaxDifficulty, + StartLocation, + VariaCustomPreset, + CustomPreset, + ]), + OptionGroup("Objectives and Endgame", [ + Objective, + CustomObjective, + CustomObjectiveCount, + CustomObjectiveList, + Tourian, + EscapeRando, + RemoveEscapeEnemies, + Animals, + ]), + OptionGroup("Areas and Layout", [ + AreaRandomization, + AreaLayout, + DoorsColorsRando, + AllowGreyDoors, + BossRandomization, + LayoutPatches, + ]), + OptionGroup("Item Pool", [ + MorphPlacement, + StrictMinors, + MissileQty, + SuperQty, + PowerBombQty, + MinorQty, + EnergyQty, + FunCombat, + FunMovement, + FunSuits, + ]), + OptionGroup("Misc Tweaks", [ + VariaTweaks, + GravityBehaviour, + NerfedCharge, + SpinJumpRestart, + SpeedKeep, + InfiniteSpaceJump, + RelaxedRoundRobinCF, + ]), + OptionGroup("Quality of Life", [ + ElevatorsSpeed, + DoorsSpeed, + RefillBeforeSave, + ]), + OptionGroup("Cosmetic", [ + Hud, + HideItems, + NoMusic, + RandomMusic, + ]), +] + @dataclass class SMOptions(PerGameCommonOptions): start_inventory_removes_from_pool: StartItemsRemovesFromPool preset: Preset + max_difficulty: MaxDifficulty start_location: StartLocation remote_items: RemoteItems death_link: DeathLink @@ -384,7 +469,6 @@ class SMOptions(PerGameCommonOptions): #scav_num_locs: "10" #scav_randomized: "off" #scav_escape: "off" - max_difficulty: MaxDifficulty #progression_speed": "medium" #progression_difficulty": "normal" morph_placement: MorphPlacement diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index bc8dcd6114..cdb58b72fb 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -15,7 +15,7 @@ from worlds.generic.Rules import add_rule, set_rule logger = logging.getLogger("Super Metroid") -from .Options import SMOptions +from .Options import SMOptions, sm_option_groups from .Client import SMSNIClient from .Rom import SM_ROM_MAX_PLAYERID, SM_ROM_PLAYERDATA_COUNT, SMProcedurePatch, get_sm_symbols import Utils @@ -78,6 +78,7 @@ class SMWeb(WebWorld): "multiworld/en", ["Farrak Kilhn"] )] + option_groups = sm_option_groups class ByteEdit(TypedDict): @@ -852,7 +853,7 @@ class SMWorld(World): def fill_slot_data(self): slot_data = {} if not self.multiworld.is_race: - slot_data = self.options.as_dict(*self.options_dataclass.type_hints) + slot_data = self.options.as_dict("start_location", "max_difficulty", "area_randomization", "doors_colors_rando", "boss_randomization") slot_data["Preset"] = { "Knows": {}, "Settings": {"hardRooms": Settings.SettingsDict[self.player].hardRooms, "bossesDifficulty": Settings.SettingsDict[self.player].bossesDifficulty, diff --git a/worlds/sm/variaRandomizer/logic/smbool.py b/worlds/sm/variaRandomizer/logic/smbool.py index b7f596cbbb..25f20b550a 100644 --- a/worlds/sm/variaRandomizer/logic/smbool.py +++ b/worlds/sm/variaRandomizer/logic/smbool.py @@ -66,6 +66,14 @@ class SMBool: def __copy__(self): return SMBool(self.bool, self.difficulty, self._knows, self._items) + def __deepcopy__(self, memodict): + # `bool` and `difficulty` are a `bool` and `int`, so do not need to be copied. + # The `_knows` list is never mutated, so does not need to be copied. + # The `_items` list is a `list[str | list[str]]` (copied to a flat `list[str]` when accessed through the `items` + # property) that is mutated by code in helpers.py, so needs to be copied. Because there could be lists within + # the list, it is copied using the `flatten()` helper function. + return SMBool(self.bool, self.difficulty, self._knows, flatten(self._items)) + def json(self): # as we have slots instead of dict return {'bool': self.bool, 'difficulty': self.difficulty, 'knows': self.knows, 'items': self.items} diff --git a/worlds/sm/variaRandomizer/logic/smboolmanager.py b/worlds/sm/variaRandomizer/logic/smboolmanager.py index 16f903074e..27abb0d31d 100644 --- a/worlds/sm/variaRandomizer/logic/smboolmanager.py +++ b/worlds/sm/variaRandomizer/logic/smboolmanager.py @@ -8,6 +8,7 @@ from ..utils.doorsmanager import DoorsManager from ..utils.objectives import Objectives from ..utils.parameters import Knows, isKnows import logging +from copy import deepcopy import sys class SMBoolManager(object): @@ -34,6 +35,46 @@ class SMBoolManager(object): self.createFacadeFunctions() self.createKnowsFunctions(player) self.resetItems() + self.itemsPositions = {} + + def __deepcopy__(self, memodict): + # Use __new__ to avoid calling __init__ like copy.deepcopy without __deepcopy__ implemented. + new = object.__new__(type(self)) + + # Copy everything over in the same order as __init__, ensuring that mutable attributes are deeply copied. + + # SMBool instances contain mutable lists, so must be deep-copied. + new._items = {i: deepcopy(v, memodict) for i, v in self._items.items()} + # `_counts` is a dict[str, int], so the dict can be copied because its keys and values are immutable. + new._counts = self._counts.copy() + # `player` is an int. + new.player = self.player + # `maxDiff` is an int. + new.maxDiff = self.maxDiff + # `onlyBossLeft` is a bool. + new.onlyBossLeft = self.onlyBossLeft + # The HelpersGraph keeps reference to the instance, so a new HelpersGraph is required. + new.helpers = Logic.HelpersGraph(new) + # DoorsManager is stateless, so the same instance can be used. + new.doorsManager = self.doorsManager + # Objectives are cached by self.player, so will be the same instance for the copy. + new.objectives = self.objectives + # Copy the facade functions from new.helpers into new.__dict__. + new.createFacadeFunctions() + # Copying the existing 'knows' functions from `self` to `new` is faster than re-creating all the lambdas with + # `new.createKnowsFunctions(player)`. + for key in Knows.__dict__.keys(): + if isKnows(key): + attribute_name = "knows"+key + knows_func = getattr(self, attribute_name) + setattr(new, attribute_name, knows_func) + # There is no need to call `new.resetItems()` because `_items` and `_counts` have been copied over. + # new.resetItems() + # itemsPositions is a `dict[str, tuple[int, int]]`, so the dict can be copied because the keys and values are + # immutable. + new.itemsPositions = self.itemsPositions.copy() + + return new def computeItemsPositions(self): # compute index in cache key for each items @@ -245,6 +286,9 @@ class SMBoolManagerPlando(SMBoolManager): def __init__(self): super(SMBoolManagerPlando, self).__init__() + def __deepcopy__(self, memodict): + return super().__deepcopy__(memodict) + def addItem(self, item): # a new item is available already = self.haveItem(item) diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index dca105b162..4d0b63f33c 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -97,7 +97,6 @@ class SMZ3World(World): ItemType.TwentyRupees, ItemType.FiftyRupees, ItemType.ThreeHundredRupees, - ItemType.ETank, ItemType.Missile, ItemType.Super, ItemType.PowerBomb @@ -231,7 +230,6 @@ class SMZ3World(World): niceItems = TotalSMZ3Item.Item.CreateNicePool(self.smz3World) junkItems = TotalSMZ3Item.Item.CreateJunkPool(self.smz3World) - self.junkItemsNames = [item.Type.name for item in junkItems] if (self.smz3World.Config.Keysanity): progressionItems = self.progression + self.dungeon + self.keyCardsItems + self.SmMapsItems @@ -500,7 +498,14 @@ class SMZ3World(World): multidata["connect_names"][new_name] = payload def fill_slot_data(self): - slot_data = {} + slot_data = { + "goal": self.options.goal.value, + "open_tower": self.options.open_tower.value, + "ganon_vulnerable": self.options.ganon_vulnerable.value, + "open_tourian": self.options.open_tourian.value, + "sm_logic": self.options.sm_logic.value, + "key_shuffle": self.options.key_shuffle.value, + } return slot_data def collect(self, state: CollectionState, item: Item) -> bool: diff --git a/worlds/soe/docs/multiworld_en.md b/worlds/soe/docs/multiworld_en.md index a2944d4c01..9378626df4 100644 --- a/worlds/soe/docs/multiworld_en.md +++ b/worlds/soe/docs/multiworld_en.md @@ -130,9 +130,7 @@ page: [usb2snes Supported Platforms Page](http://usb2snes.com/#supported-platfor ### Open the client -Open ap-soeclient ([Evermizer Archipelago Client Page](http://evermizer.com/apclient)) in a modern browser. Do not -switch tabs, open it in a new window if you want to use the browser while playing. Do not minimize the window with the -client. +Open ap-soeclient ([Evermizer Archipelago Client Page](http://evermizer.com/apclient)) in a modern browser. The client should automatically connect to SNI, the "SNES" status should change to green. diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py index 9a05c04d51..ec96a9949e 100644 --- a/worlds/stardew_valley/__init__.py +++ b/worlds/stardew_valley/__init__.py @@ -1,9 +1,10 @@ import logging import typing from random import Random -from typing import Dict, Any, Iterable, Optional, List, TextIO +from typing import Dict, Any, Optional, List, TextIO -from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState +import entrance_rando +from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState from Options import PerGameCommonOptions from worlds.AutoWorld import World, WebWorld from .bundles.bundle_room import BundleRoom @@ -21,7 +22,7 @@ from .options.forced_options import force_change_options_if_incompatible from .options.option_groups import sv_option_groups from .options.presets import sv_options_presets from .options.worlds_group import apply_most_restrictive_options -from .regions import create_regions +from .regions import create_regions, prepare_mod_data from .rules import set_rules from .stardew_rule import True_, StardewRule, HasProgressionPercent from .strings.ap_names.event_names import Event @@ -49,15 +50,25 @@ class StardewWebWorld(WebWorld): options_presets = sv_options_presets option_groups = sv_option_groups - tutorials = [ - Tutorial( - "Multiworld Setup Guide", - "A guide to playing Stardew Valley with Archipelago.", - "English", - "setup_en.md", - "setup/en", - ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"] - )] + setup_en = Tutorial( + "Multiworld Setup Guide", + "A guide to playing Stardew Valley with Archipelago.", + "English", + "setup_en.md", + "setup/en", + ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"] + ) + + setup_fr = Tutorial( + "Guide de configuration MultiWorld", + "Un guide pour configurer Stardew Valley sur Archipelago", + "Français", + "setup_fr.md", + "setup/fr", + ["Eindall"] + ) + + tutorials = [setup_en, setup_fr] class StardewValleyWorld(World): @@ -124,18 +135,13 @@ class StardewValleyWorld(World): self.content = create_content(self.options) def create_regions(self): - def create_region(name: str, exits: Iterable[str]) -> Region: - region = Region(name, self.player, self.multiworld) - region.exits = [Entrance(self.player, exit_name, region) for exit_name in exits] - return region + def create_region(name: str) -> Region: + return Region(name, self.player, self.multiworld) - world_regions, world_entrances, self.randomized_entrances = create_regions(create_region, self.random, self.options, self.content) + world_regions = create_regions(create_region, self.options, self.content) self.logic = StardewLogic(self.player, self.options, self.content, world_regions.keys()) - self.modified_bundles = get_all_bundles(self.random, - self.logic, - self.content, - self.options) + self.modified_bundles = get_all_bundles(self.random, self.logic, self.content, self.options) def add_location(name: str, code: Optional[int], region: str): region: Region = world_regions[region] @@ -308,6 +314,11 @@ class StardewValleyWorld(World): def set_rules(self): set_rules(self) + def connect_entrances(self) -> None: + no_target_groups = {0: [0]} + placement = entrance_rando.randomize_entrances(self, coupled=True, target_group_lookup=no_target_groups) + self.randomized_entrances = prepare_mod_data(placement) + def generate_basic(self): pass diff --git a/worlds/stardew_valley/content/mods/sve.py b/worlds/stardew_valley/content/mods/sve.py index 12b3e3558a..3784723737 100644 --- a/worlds/stardew_valley/content/mods/sve.py +++ b/worlds/stardew_valley/content/mods/sve.py @@ -24,6 +24,9 @@ from ...strings.skill_names import Skill from ...strings.tool_names import Tool, ToolMaterial from ...strings.villager_names import ModNPC +# Used to adapt content not yet moved to content packs to easily detect when SVE and Ginger Island are both enabled. +SVE_GINGER_ISLAND_PACK = ModNames.sve + "+" + ginger_island_content_pack.name + class SVEContentPack(ContentPack): @@ -67,6 +70,10 @@ class SVEContentPack(ContentPack): content.game_items.pop(SVESeed.slime) content.game_items.pop(SVEFruit.slime_berry) + def finalize_hook(self, content: StardewContent): + if ginger_island_content_pack.name in content.registered_packs: + content.registered_packs.add(SVE_GINGER_ISLAND_PACK) + register_mod_content_pack(SVEContentPack( ModNames.sve, @@ -80,8 +87,9 @@ register_mod_content_pack(SVEContentPack( ModEdible.lightning_elixir: (ShopSource(money_price=12000, shop_region=SVERegion.galmoran_outpost),), ModEdible.barbarian_elixir: (ShopSource(money_price=22000, shop_region=SVERegion.galmoran_outpost),), ModEdible.gravity_elixir: (ShopSource(money_price=4000, shop_region=SVERegion.galmoran_outpost),), - SVEMeal.grampleton_orange_chicken: ( - ShopSource(money_price=650, shop_region=Region.saloon, other_requirements=(RelationshipRequirement(ModNPC.sophia, 6),)),), + SVEMeal.grampleton_orange_chicken: (ShopSource(money_price=650, + shop_region=Region.saloon, + other_requirements=(RelationshipRequirement(ModNPC.sophia, 6),)),), ModEdible.hero_elixir: (ShopSource(money_price=8000, shop_region=SVERegion.isaac_shop),), ModEdible.aegis_elixir: (ShopSource(money_price=28000, shop_region=SVERegion.galmoran_outpost),), SVEBeverage.sports_drink: (ShopSource(money_price=750, shop_region=Region.hospital),), @@ -118,8 +126,8 @@ register_mod_content_pack(SVEContentPack( ModLoot.green_mushroom: (ForagingSource(regions=(SVERegion.highlands_pond,), seasons=Season.not_winter),), ModLoot.ornate_treasure_chest: (ForagingSource(regions=(SVERegion.highlands_outside,), - other_requirements=( - CombatRequirement(Performance.galaxy), ToolRequirement(Tool.axe, ToolMaterial.iron))),), + other_requirements=(CombatRequirement(Performance.galaxy), + ToolRequirement(Tool.axe, ToolMaterial.iron))),), ModLoot.swirl_stone: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.galaxy),)),), ModLoot.void_soul: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.good),)),), SVEForage.winter_star_rose: (ForagingSource(regions=(SVERegion.summit,), seasons=(Season.winter,)),), @@ -139,8 +147,9 @@ register_mod_content_pack(SVEContentPack( SVEForage.thistle: (ForagingSource(regions=(SVERegion.summit,)),), ModLoot.void_pebble: (ForagingSource(regions=(SVERegion.crimson_badlands,), other_requirements=(CombatRequirement(Performance.great),)),), ModLoot.void_shard: (ForagingSource(regions=(SVERegion.crimson_badlands,), - other_requirements=( - CombatRequirement(Performance.galaxy), SkillRequirement(Skill.combat, 10), YearRequirement(3),)),), + other_requirements=(CombatRequirement(Performance.galaxy), + SkillRequirement(Skill.combat, 10), + YearRequirement(3),)),), SVEWaterItem.dulse_seaweed: (ForagingSource(regions=(Region.beach,), other_requirements=(FishingRequirement(Region.beach),)),), # Fable Reef @@ -207,7 +216,6 @@ register_mod_content_pack(SVEContentPack( villagers_data.scarlett, villagers_data.susan, villagers_data.morris, - # The wizard leaves his tower on sunday, for like 1 hour... Good enough for entrance rando! - override(villagers_data.wizard, locations=(Region.wizard_tower, Region.forest), bachelor=True, mod_name=ModNames.sve), + override(villagers_data.wizard, bachelor=True, mod_name=ModNames.sve), ) )) diff --git a/worlds/stardew_valley/content/vanilla/pelican_town.py b/worlds/stardew_valley/content/vanilla/pelican_town.py index aeae4c1431..d1d024b54c 100644 --- a/worlds/stardew_valley/content/vanilla/pelican_town.py +++ b/worlds/stardew_valley/content/vanilla/pelican_town.py @@ -3,7 +3,7 @@ from ...data import villagers_data, fish_data from ...data.building import Building from ...data.game_item import GenericSource, ItemTag, Tag, CustomRuleSource from ...data.harvest import ForagingSource, SeasonalForagingSource, ArtifactSpotSource -from ...data.requirement import ToolRequirement, BookRequirement, SkillRequirement +from ...data.requirement import ToolRequirement, BookRequirement, SkillRequirement, YearRequirement from ...data.shop import ShopSource, MysteryBoxSource, ArtifactTroveSource, PrizeMachineSource, FishingTreasureChestSource from ...strings.artisan_good_names import ArtisanGood from ...strings.book_names import Book @@ -209,7 +209,7 @@ pelican_town = ContentPack( # Books Book.animal_catalogue: ( Tag(ItemTag.BOOK, ItemTag.BOOK_POWER), - ShopSource(money_price=5000, shop_region=Region.ranch),), + ShopSource(money_price=5000, shop_region=Region.ranch, other_requirements=(YearRequirement(2),)),), Book.book_of_mysteries: ( Tag(ItemTag.BOOK, ItemTag.BOOK_POWER), MysteryBoxSource(amount=38),), # After 38 boxes, there are 49.99% chances player received the book. diff --git a/worlds/stardew_valley/data/bundle_data.py b/worlds/stardew_valley/data/bundle_data.py index 3a5523ecdd..3f289d33cd 100644 --- a/worlds/stardew_valley/data/bundle_data.py +++ b/worlds/stardew_valley/data/bundle_data.py @@ -271,11 +271,11 @@ solar_essence = BundleItem(Loot.solar_essence) void_essence = BundleItem(Loot.void_essence) petrified_slime = BundleItem(Mineral.petrified_slime) -blue_slime_egg = BundleItem(Loot.blue_slime_egg) -red_slime_egg = BundleItem(Loot.red_slime_egg) -purple_slime_egg = BundleItem(Loot.purple_slime_egg) -green_slime_egg = BundleItem(Loot.green_slime_egg) -tiger_slime_egg = BundleItem(Loot.tiger_slime_egg, source=BundleItem.Sources.island) +blue_slime_egg = BundleItem(AnimalProduct.slime_egg_blue) +red_slime_egg = BundleItem(AnimalProduct.slime_egg_red) +purple_slime_egg = BundleItem(AnimalProduct.slime_egg_purple) +green_slime_egg = BundleItem(AnimalProduct.slime_egg_green) +tiger_slime_egg = BundleItem(AnimalProduct.slime_egg_tiger, source=BundleItem.Sources.island) cherry_bomb = BundleItem(Bomb.cherry_bomb, 5) bomb = BundleItem(Bomb.bomb, 2) diff --git a/worlds/stardew_valley/data/craftable_data.py b/worlds/stardew_valley/data/craftable_data.py index de371b7c3a..3dae67c260 100644 --- a/worlds/stardew_valley/data/craftable_data.py +++ b/worlds/stardew_valley/data/craftable_data.py @@ -305,7 +305,7 @@ hopper = ap_recipe(Craftable.hopper, {Material.hardwood: 10, MetalBar.iridium: 1 cookout_kit = skill_recipe(Craftable.cookout_kit, Skill.foraging, 3, {Material.wood: 15, Material.fiber: 10, Material.coal: 3}) tent_kit = skill_recipe(Craftable.tent_kit, Skill.foraging, 8, {Material.hardwood: 10, Material.fiber: 25, ArtisanGood.cloth: 1}) -statue_of_blessings = mastery_recipe(Statue.blessings, Skill.farming, {Material.sap: 999, Material.fiber: 999, Material.stone: 999}) +statue_of_blessings = mastery_recipe(Statue.blessings, Skill.farming, {Material.sap: 999, Material.fiber: 999, Material.stone: 999, Material.moss: 333}) statue_of_dwarf_king = mastery_recipe(Statue.dwarf_king, Skill.mining, {MetalBar.iridium: 20}) heavy_furnace = mastery_recipe(Machine.heavy_furnace, Skill.mining, {Machine.furnace: 2, MetalBar.iron: 3, Material.stone: 50}) mystic_tree_seed = mastery_recipe(TreeSeed.mystic, Skill.foraging, {TreeSeed.acorn: 5, TreeSeed.maple: 5, TreeSeed.pine: 5, TreeSeed.mahogany: 5}) @@ -386,7 +386,7 @@ coppper_slot_machine = skill_recipe(ModMachine.copper_slot_machine, ModSkill.luc Forageable.salmonberry: 1, Material.clay: 1, Trash.joja_cola: 1}, ModNames.luck_skill) gold_slot_machine = skill_recipe(ModMachine.gold_slot_machine, ModSkill.luck, 4, {MetalBar.gold: 15, ModMachine.copper_slot_machine: 1}, ModNames.luck_skill) -iridium_slot_machine = skill_recipe(ModMachine.iridium_slot_machine, ModSkill.luck, 4, {MetalBar.iridium: 15, ModMachine.gold_slot_machine: 1}, ModNames.luck_skill) -radioactive_slot_machine = skill_recipe(ModMachine.radioactive_slot_machine, ModSkill.luck, 4, {MetalBar.radioactive: 15, ModMachine.iridium_slot_machine: 1}, ModNames.luck_skill) +iridium_slot_machine = skill_recipe(ModMachine.iridium_slot_machine, ModSkill.luck, 6, {MetalBar.iridium: 15, ModMachine.gold_slot_machine: 1}, ModNames.luck_skill) +radioactive_slot_machine = skill_recipe(ModMachine.radioactive_slot_machine, ModSkill.luck, 8, {MetalBar.radioactive: 15, ModMachine.iridium_slot_machine: 1}, ModNames.luck_skill) all_crafting_recipes_by_name = {recipe.item: recipe for recipe in all_crafting_recipes} diff --git a/worlds/stardew_valley/data/items.csv b/worlds/stardew_valley/data/items.csv index 11a22e952d..f7dac9c579 100644 --- a/worlds/stardew_valley/data/items.csv +++ b/worlds/stardew_valley/data/items.csv @@ -6,7 +6,7 @@ id,name,classification,groups,mod_name 18,Greenhouse,progression,COMMUNITY_REWARD, 19,Glittering Boulder Removed,progression,COMMUNITY_REWARD, 20,Minecarts Repair,useful,COMMUNITY_REWARD, -21,Bus Repair,progression,COMMUNITY_REWARD, +21,Bus Repair,progression,"COMMUNITY_REWARD,DESERT_TRANSPORTATION", 22,Progressive Movie Theater,"progression,trap",COMMUNITY_REWARD, 23,Stardrop,progression,, 24,Progressive Backpack,progression,, @@ -63,8 +63,8 @@ id,name,classification,groups,mod_name 77,Combat Level,progression,SKILL_LEVEL_UP, 78,Earth Obelisk,progression,WIZARD_BUILDING, 79,Water Obelisk,progression,WIZARD_BUILDING, -80,Desert Obelisk,progression,WIZARD_BUILDING, -81,Island Obelisk,progression,"WIZARD_BUILDING,GINGER_ISLAND", +80,Desert Obelisk,progression,"WIZARD_BUILDING,DESERT_TRANSPORTATION", +81,Island Obelisk,progression,"WIZARD_BUILDING,GINGER_ISLAND,ISLAND_TRANSPORTATION", 82,Junimo Hut,useful,WIZARD_BUILDING, 83,Gold Clock,progression,WIZARD_BUILDING, 84,Progressive Coop,progression,BUILDING, @@ -242,7 +242,7 @@ id,name,classification,groups,mod_name 257,Peach Sapling,progression,"RESOURCE_PACK,RESOURCE_PACK_USEFUL,CROPSANITY", 258,Banana Sapling,progression,"GINGER_ISLAND,RESOURCE_PACK,RESOURCE_PACK_USEFUL,CROPSANITY", 259,Mango Sapling,progression,"GINGER_ISLAND,RESOURCE_PACK,RESOURCE_PACK_USEFUL,CROPSANITY", -260,Boat Repair,progression,GINGER_ISLAND, +260,Boat Repair,progression,"GINGER_ISLAND,ISLAND_TRANSPORTATION", 261,Open Professor Snail Cave,progression,GINGER_ISLAND, 262,Island North Turtle,progression,"GINGER_ISLAND,WALNUT_PURCHASE", 263,Island West Turtle,progression,"GINGER_ISLAND,WALNUT_PURCHASE", diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv index 66a9157b34..14554a3bcd 100644 --- a/worlds/stardew_valley/data/locations.csv +++ b/worlds/stardew_valley/data/locations.csv @@ -1129,8 +1129,8 @@ id,region,name,tags,mod_name 2204,Leo's Hut,Leo's Parrot,"GINGER_ISLAND,WALNUT_PURCHASE", 2205,Island South,Island West Turtle,"GINGER_ISLAND,WALNUT_PURCHASE", 2206,Island West,Island Farmhouse,"GINGER_ISLAND,WALNUT_PURCHASE", -2207,Island Farmhouse,Island Mailbox,"GINGER_ISLAND,WALNUT_PURCHASE", -2208,Island Farmhouse,Farm Obelisk,"GINGER_ISLAND,WALNUT_PURCHASE", +2207,Island West,Island Mailbox,"GINGER_ISLAND,WALNUT_PURCHASE", +2208,Island West,Farm Obelisk,"GINGER_ISLAND,WALNUT_PURCHASE", 2209,Island North,Dig Site Bridge,"GINGER_ISLAND,WALNUT_PURCHASE", 2210,Island North,Island Trader,"GINGER_ISLAND,WALNUT_PURCHASE", 2211,Volcano Entrance,Volcano Bridge,"GINGER_ISLAND,WALNUT_PURCHASE", @@ -2316,100 +2316,100 @@ id,region,name,tags,mod_name 4069,Museum,Read Note From Gunther,"BOOKSANITY,BOOKSANITY_LOST", 4070,Museum,Read Goblins by M. Jasper,"BOOKSANITY,BOOKSANITY_LOST", 4071,Museum,Read Secret Statues Acrostics,"BOOKSANITY,BOOKSANITY_LOST", -4101,Clint's Blacksmith,Open Golden Coconut,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4102,Island West,Fishing Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4103,Island West,Fishing Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4104,Island North,Fishing Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4105,Island North,Fishing Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4106,Island Southeast,Fishing Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4107,Island East,Jungle Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4108,Island East,Banana Altar,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4109,Leo's Hut,Leo's Tree,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4110,Island Shrine,Gem Birds Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4111,Island Shrine,Gem Birds Shrine,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4112,Island West,Harvesting Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4113,Island West,Harvesting Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4114,Island West,Harvesting Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4115,Island West,Harvesting Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4116,Island West,Harvesting Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4117,Gourmand Frog Cave,Gourmand Frog Melon,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4118,Gourmand Frog Cave,Gourmand Frog Wheat,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4119,Gourmand Frog Cave,Gourmand Frog Garlic,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4120,Island West,Journal Scrap #6,"WALNUTSANITY,WALNUTSANITY_DIG", -4121,Island West,Mussel Node Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4122,Island West,Mussel Node Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4123,Island West,Mussel Node Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4124,Island West,Mussel Node Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4125,Island West,Mussel Node Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4126,Shipwreck,Shipwreck Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4127,Island West,Whack A Mole,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4128,Island West,Starfish Triangle,"WALNUTSANITY,WALNUTSANITY_DIG", -4129,Island West,Starfish Diamond,"WALNUTSANITY,WALNUTSANITY_DIG", -4130,Island West,X in the sand,"WALNUTSANITY,WALNUTSANITY_DIG", -4131,Island West,Diamond Of Indents,"WALNUTSANITY,WALNUTSANITY_DIG", -4132,Island West,Bush Behind Coconut Tree,"WALNUTSANITY,WALNUTSANITY_BUSH", -4133,Island West,Journal Scrap #4,"WALNUTSANITY,WALNUTSANITY_DIG", -4134,Island West,Walnut Room Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4135,Island West,Coast Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4136,Island West,Tiger Slime Walnut,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4137,Island West,Bush Behind Mahogany Tree,"WALNUTSANITY,WALNUTSANITY_BUSH", -4138,Island West,Circle Of Grass,"WALNUTSANITY,WALNUTSANITY_DIG", -4139,Island West,Below Colored Crystals Cave Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4140,Colored Crystals Cave,Colored Crystals,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4141,Island West,Cliff Edge Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4142,Island West,Diamond Of Pebbles,"WALNUTSANITY,WALNUTSANITY_DIG", -4143,Island West,Farm Parrot Express Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4144,Island West,Farmhouse Cliff Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4145,Island North,Big Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", -4146,Island North,Grove Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4147,Island North,Diamond Of Grass,"WALNUTSANITY,WALNUTSANITY_DIG", -4148,Island North,Small Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", -4149,Island North,Patch Of Sand,"WALNUTSANITY,WALNUTSANITY_DIG", -4150,Dig Site,Crooked Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", -4151,Dig Site,Above Dig Site Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4152,Dig Site,Above Field Office Bush 1,"WALNUTSANITY,WALNUTSANITY_BUSH", -4153,Dig Site,Above Field Office Bush 2,"WALNUTSANITY,WALNUTSANITY_BUSH", -4154,Field Office,Complete Large Animal Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4155,Field Office,Complete Snake Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4156,Field Office,Complete Mummified Frog Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4157,Field Office,Complete Mummified Bat Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4158,Field Office,Purple Flowers Island Survey,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4159,Field Office,Purple Starfish Island Survey,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4160,Island North,Bush Behind Volcano Tree,"WALNUTSANITY,WALNUTSANITY_BUSH", -4161,Island North,Arc Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", -4162,Island North,Protruding Tree Walnut,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4163,Island North,Journal Scrap #10,"WALNUTSANITY,WALNUTSANITY_DIG", -4164,Island North,Northmost Point Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", -4165,Island North,Hidden Passage Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4166,Volcano Secret Beach,Secret Beach Bush 1,"WALNUTSANITY,WALNUTSANITY_BUSH", -4167,Volcano Secret Beach,Secret Beach Bush 2,"WALNUTSANITY,WALNUTSANITY_BUSH", -4168,Volcano - Floor 5,Volcano Rocks Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4169,Volcano - Floor 5,Volcano Rocks Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4170,Volcano - Floor 10,Volcano Rocks Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4171,Volcano - Floor 10,Volcano Rocks Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4172,Volcano - Floor 10,Volcano Rocks Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4173,Volcano - Floor 5,Volcano Monsters Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4174,Volcano - Floor 5,Volcano Monsters Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4175,Volcano - Floor 10,Volcano Monsters Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4176,Volcano - Floor 10,Volcano Monsters Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4177,Volcano - Floor 10,Volcano Monsters Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4178,Volcano - Floor 5,Volcano Crates Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4179,Volcano - Floor 5,Volcano Crates Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4180,Volcano - Floor 10,Volcano Crates Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4181,Volcano - Floor 10,Volcano Crates Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4182,Volcano - Floor 10,Volcano Crates Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4183,Volcano - Floor 5,Volcano Common Chest Walnut,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4184,Volcano - Floor 10,Volcano Rare Chest Walnut,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", -4185,Volcano - Floor 10,Forge Entrance Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4186,Volcano - Floor 10,Forge Exit Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4187,Island North,Cliff Over Island South Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", -4188,Island Southeast,Starfish Tide Pool,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4189,Island Southeast,Diamond Of Yellow Starfish,"WALNUTSANITY,WALNUTSANITY_DIG", -4190,Island Southeast,Mermaid Song,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4191,Pirate Cove,Pirate Darts 1,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4192,Pirate Cove,Pirate Darts 2,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4193,Pirate Cove,Pirate Darts 3,"WALNUTSANITY,WALNUTSANITY_PUZZLE", -4194,Pirate Cove,Pirate Cove Patch Of Sand,"WALNUTSANITY,WALNUTSANITY_DIG", +4101,Clint's Blacksmith,Walnutsanity: Open Golden Coconut,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4102,Island West,Walnutsanity: Fishing Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4103,Island West,Walnutsanity: Fishing Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4104,Island North,Walnutsanity: Fishing Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4105,Island North,Walnutsanity: Fishing Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4106,Island Southeast,Walnutsanity: Fishing Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4107,Island East,Walnutsanity: Jungle Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4108,Island East,Walnutsanity: Banana Altar,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4109,Leo's Hut,Walnutsanity: Leo's Tree,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4110,Island Shrine,Walnutsanity: Gem Birds Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4111,Island Shrine,Walnutsanity: Gem Birds Shrine,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4112,Island West,Walnutsanity: Harvesting Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4113,Island West,Walnutsanity: Harvesting Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4114,Island West,Walnutsanity: Harvesting Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4115,Island West,Walnutsanity: Harvesting Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4116,Island West,Walnutsanity: Harvesting Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4117,Gourmand Frog Cave,Walnutsanity: Gourmand Frog Melon,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4118,Gourmand Frog Cave,Walnutsanity: Gourmand Frog Wheat,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4119,Gourmand Frog Cave,Walnutsanity: Gourmand Frog Garlic,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4120,Island West,Walnutsanity: Journal Scrap #6,"WALNUTSANITY,WALNUTSANITY_DIG", +4121,Island West,Walnutsanity: Mussel Node Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4122,Island West,Walnutsanity: Mussel Node Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4123,Island West,Walnutsanity: Mussel Node Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4124,Island West,Walnutsanity: Mussel Node Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4125,Island West,Walnutsanity: Mussel Node Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4126,Shipwreck,Walnutsanity: Shipwreck Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4127,Island West,Walnutsanity: Whack A Mole,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4128,Island West,Walnutsanity: Starfish Triangle,"WALNUTSANITY,WALNUTSANITY_DIG", +4129,Island West,Walnutsanity: Starfish Diamond,"WALNUTSANITY,WALNUTSANITY_DIG", +4130,Island West,Walnutsanity: X in the sand,"WALNUTSANITY,WALNUTSANITY_DIG", +4131,Island West,Walnutsanity: Diamond Of Indents,"WALNUTSANITY,WALNUTSANITY_DIG", +4132,Island West,Walnutsanity: Bush Behind Coconut Tree,"WALNUTSANITY,WALNUTSANITY_BUSH", +4133,Island West,Walnutsanity: Journal Scrap #4,"WALNUTSANITY,WALNUTSANITY_DIG", +4134,Island West,Walnutsanity: Walnut Room Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4135,Island West,Walnutsanity: Coast Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4136,Island West,Walnutsanity: Tiger Slime Walnut,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4137,Island West,Walnutsanity: Bush Behind Mahogany Tree,"WALNUTSANITY,WALNUTSANITY_BUSH", +4138,Island West,Walnutsanity: Circle Of Grass,"WALNUTSANITY,WALNUTSANITY_DIG", +4139,Island West,Walnutsanity: Below Colored Crystals Cave Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4140,Colored Crystals Cave,Walnutsanity: Colored Crystals,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4141,Island West,Walnutsanity: Cliff Edge Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4142,Island West,Walnutsanity: Diamond Of Pebbles,"WALNUTSANITY,WALNUTSANITY_DIG", +4143,Island West,Walnutsanity: Farm Parrot Express Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4144,Island West,Walnutsanity: Farmhouse Cliff Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4145,Island North,Walnutsanity: Big Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", +4146,Island North,Walnutsanity: Grove Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4147,Island North,Walnutsanity: Diamond Of Grass,"WALNUTSANITY,WALNUTSANITY_DIG", +4148,Island North,Walnutsanity: Small Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", +4149,Island North,Walnutsanity: Patch Of Sand,"WALNUTSANITY,WALNUTSANITY_DIG", +4150,Dig Site,Walnutsanity: Crooked Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", +4151,Dig Site,Walnutsanity: Above Dig Site Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4152,Dig Site,Walnutsanity: Above Field Office Bush 1,"WALNUTSANITY,WALNUTSANITY_BUSH", +4153,Dig Site,Walnutsanity: Above Field Office Bush 2,"WALNUTSANITY,WALNUTSANITY_BUSH", +4154,Field Office,Walnutsanity: Complete Large Animal Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4155,Field Office,Walnutsanity: Complete Snake Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4156,Field Office,Walnutsanity: Complete Mummified Frog Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4157,Field Office,Walnutsanity: Complete Mummified Bat Collection,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4158,Field Office,Walnutsanity: Purple Flowers Island Survey,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4159,Field Office,Walnutsanity: Purple Starfish Island Survey,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4160,Island North,Walnutsanity: Bush Behind Volcano Tree,"WALNUTSANITY,WALNUTSANITY_BUSH", +4161,Island North,Walnutsanity: Arc Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", +4162,Island North,Walnutsanity: Protruding Tree Walnut,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4163,Island North,Walnutsanity: Journal Scrap #10,"WALNUTSANITY,WALNUTSANITY_DIG", +4164,Island North,Walnutsanity: Northmost Point Circle Of Stones,"WALNUTSANITY,WALNUTSANITY_DIG", +4165,Island North,Walnutsanity: Hidden Passage Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4166,Volcano Secret Beach,Walnutsanity: Secret Beach Bush 1,"WALNUTSANITY,WALNUTSANITY_BUSH", +4167,Volcano Secret Beach,Walnutsanity: Secret Beach Bush 2,"WALNUTSANITY,WALNUTSANITY_BUSH", +4168,Volcano - Floor 5,Walnutsanity: Volcano Rocks Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4169,Volcano - Floor 5,Walnutsanity: Volcano Rocks Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4170,Volcano - Floor 10,Walnutsanity: Volcano Rocks Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4171,Volcano - Floor 10,Walnutsanity: Volcano Rocks Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4172,Volcano - Floor 10,Walnutsanity: Volcano Rocks Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4173,Volcano - Floor 5,Walnutsanity: Volcano Monsters Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4174,Volcano - Floor 5,Walnutsanity: Volcano Monsters Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4175,Volcano - Floor 10,Walnutsanity: Volcano Monsters Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4176,Volcano - Floor 10,Walnutsanity: Volcano Monsters Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4177,Volcano - Floor 10,Walnutsanity: Volcano Monsters Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4178,Volcano - Floor 5,Walnutsanity: Volcano Crates Walnut 1,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4179,Volcano - Floor 5,Walnutsanity: Volcano Crates Walnut 2,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4180,Volcano - Floor 10,Walnutsanity: Volcano Crates Walnut 3,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4181,Volcano - Floor 10,Walnutsanity: Volcano Crates Walnut 4,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4182,Volcano - Floor 10,Walnutsanity: Volcano Crates Walnut 5,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4183,Volcano - Floor 5,Walnutsanity: Volcano Common Chest Walnut,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4184,Volcano - Floor 10,Walnutsanity: Volcano Rare Chest Walnut,"WALNUTSANITY,WALNUTSANITY_REPEATABLE", +4185,Volcano - Floor 10,Walnutsanity: Forge Entrance Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4186,Volcano - Floor 10,Walnutsanity: Forge Exit Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4187,Island North,Walnutsanity: Cliff Over Island South Bush,"WALNUTSANITY,WALNUTSANITY_BUSH", +4188,Island Southeast,Walnutsanity: Starfish Tide Pool,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4189,Island Southeast,Walnutsanity: Diamond Of Yellow Starfish,"WALNUTSANITY,WALNUTSANITY_DIG", +4190,Island Southeast,Walnutsanity: Mermaid Song,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4191,Pirate Cove,Walnutsanity: Pirate Darts 1,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4192,Pirate Cove,Walnutsanity: Pirate Darts 2,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4193,Pirate Cove,Walnutsanity: Pirate Darts 3,"WALNUTSANITY,WALNUTSANITY_PUZZLE", +4194,Pirate Cove,Walnutsanity: Pirate Cove Patch Of Sand,"WALNUTSANITY,WALNUTSANITY_DIG", 5001,Stardew Valley,Level 1 Luck,"LUCK_LEVEL,SKILL_LEVEL",Luck Skill 5002,Stardew Valley,Level 2 Luck,"LUCK_LEVEL,SKILL_LEVEL",Luck Skill 5003,Stardew Valley,Level 3 Luck,"LUCK_LEVEL,SKILL_LEVEL",Luck Skill diff --git a/worlds/stardew_valley/docs/setup_fr.md b/worlds/stardew_valley/docs/setup_fr.md new file mode 100644 index 0000000000..d7866c0b16 --- /dev/null +++ b/worlds/stardew_valley/docs/setup_fr.md @@ -0,0 +1,87 @@ +# Guide de configuration du Randomizer Stardew Valley + +## Logiciels nécessaires + +- Stardew Valley 1.6 sur PC (Recommandé: [Steam](https://store.steampowered.com/app/413150/Stardew_Valley/)) +- SMAPI ([Mod loader pour Stardew Valley](https://www.nexusmods.com/stardewvalley/mods/2400?tab=files)) +- [StardewArchipelago Version 6.x.x](https://github.com/agilbert1412/StardewArchipelago/releases) + - Il est important d'utiliser une release en 6.x.x pour jouer sur des seeds générées ici. Les versions ultérieures peuvent uniquement être utilisées pour des release ultérieures du générateur de mondes, qui ne sont pas encore hébergées sur archipelago.gg + +## Logiciels optionnels + +- Launcher Archipelago à partir de la [page des versions d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) + - (Uniquement pour le client textuel) +- Autres [mods supportés](https://github.com/agilbert1412/StardewArchipelago/blob/6.x.x/Documentation/Supported%20Mods.md) que vous pouvez ajouter au yaml pour les inclure dans la randomization d'Archipelago + + - Il n'est **pas** recommandé de modder Stardew Valley avec des mods non supportés, même s'il est possible de le faire. + Les interactions entre mods peuvent être imprévisibles, et aucune aide ne sera fournie pour les bugs qui y sont liés. + - Plus vous avez de mods non supportés, et plus ils sont gros, plus vous avez de chances de casser des choses. + +## Configuration du fichier YAML + +### Qu'est qu'un fichier YAML et pourquoi en ai-je besoin ? + +Voir le guide pour paramètrer un fichier YAML dans le guide de configuration d'Archipelago (en anglais): [Guide de configuration d'un MultiWorld basique](/tutorial/Archipelago/setup/en) + +### Où puis-je récupèrer un fichier YAML + +Vous pouvez personnaliser vos options en visitant la [Page d'options de joueur pour Stardew Valley](/games/Stardew%20Valley/player-options) + +## Rejoindre une partie en MultiWorld + +### Installation du mod + +- Installer [SMAPI](https://www.nexusmods.com/stardewvalley/mods/2400?tab=files) en suivant les instructions sur la page du mod. +- Télécharger et extraire le mod [StardewArchipelago](https://github.com/agilbert1412/StardewArchipelago/releases) dans le dossier "Mods" de Stardew Valley. +- *Optionnel*: Si vous voulez lancer le jeu depuis Steam, ajouter l'option de lancement suivante à Stardew Valley : `"[PATH TO STARDEW VALLEY]\Stardew Valley\StardewModdingAPI.exe" %command%` +- Sinon, exécutez juste "StardewModdingAPI.exe" dans le dossier d'installation de Stardew Valley. +- Stardew Valley devrait se lancer avec une console qui liste les informations des mods installés, et intéragit avec certains d'entre eux. + +### Se connecter au MultiServer + +Lancer Stardew Valley avec SMAPI. Une fois que vous avez atteint l'écran titre du jeu, créez une nouvelle ferme. + +Dans la fenêtre de création de personnage, vous verrez 3 nouveaux champs, qui permettent de relier votre personnage à un MultiWorld Archipelago. + +![image](https://i.imgur.com/b8KZy2F.png) + +Vous pouvez personnaliser votre personnage comme vous le souhaitez. + +Le champ "Server" nécessite l'adresse **et** le port, et le "Slotname" est le nom que vous avez spécifié dans votre YAML. + +`archipelago.gg:12345` + +`StardewPlayer` + +Le mot de passe est optionnel. + +Votre jeu se connectera automatiquement à Archipelago, et se reconnectera automatiquement également quand vous chargerez votre sauvegarde, plus tard. + +Vous n'aurez plus besoin d'entrer ces informations à nouveau pour ce personnage, à moins que votre session ne change d'ip ou de port. +Si l'ip ou le port de la session **change**, vous pouvez suivre ces instructions pour modifier les informations de connexion liées à votre sauvegarde : + +- Lancer Stardew Valley moddé +- Dans le **menu principal** du jeu, entrer la commande suivante **dans la console de SMAPI** : +- `connect_override ip:port slot password` +- Par exemple : `connect_override archipelago.gg:54321 StardewPlayer` +- Chargez votre partie. Les nouvelles informations de connexion seront utilisées à la place de celles enregistrées initialement. +- Jouez une journée, dormez et sauvegarder la partie. Les nouvelles informations de connexion iront écraser les précédentes, et deviendront permanentes. + +### Intéragir avec le MultiWorld depuis le jeu + +Quand vous vous connectez, vous devriez voir un message dans le chat vous informant de l'existence de la commande `!!help`. Cette commande liste les autres commandes exclusives à Stardew Valley que vous pouvez utiliser. + +De plus, vous pouvez utiliser le chat en jeu pour parler aux autres joueurs du MultiWorld, pour peu qu'ils aient un jeu qui supporte le chat. + +Enfin, vous pouvez également utiliser les commandes Archipelago (`!help` pour les lister) depuis le chat du jeu, permettant de demander des indices (via la commande `!hint`) sur certains objets. + +Il est important de préciser que le chat de Stardew Valley est assez limité. Par exemple, il ne permet pas de remonter l'historique de conversation. La console SMAPI qui tourne à côté aura quant à elle l'historique complet et sera plus pratique pour consulter des messages moins récents. +Pour une meilleure expérience avec le chat, vous pouvez aussi utiliser le client textuel d'Archipelago, bien qu'il ne permettra pas de lancer les commandes exclusives à Stardew Valley. + +### Jouer avec des mods supportés + +Voir la [documentation des mods supportés](https://github.com/agilbert1412/StardewArchipelago/blob/6.x.x/Documentation/Supported%20Mods.md) (en Anglais). + +### Multijoueur + +Vous ne pouvez pas jouer à Stardew Valley en mode multijoueur pour le moment. Il n'y a aucun plan d'action pour ajouter cette fonctionalité à court terme. \ No newline at end of file diff --git a/worlds/stardew_valley/items/item_data.py b/worlds/stardew_valley/items/item_data.py index e7c3779e27..6abc96f4e6 100644 --- a/worlds/stardew_valley/items/item_data.py +++ b/worlds/stardew_valley/items/item_data.py @@ -33,6 +33,8 @@ class Group(enum.Enum): SKILL_MASTERY = enum.auto() BUILDING = enum.auto() WIZARD_BUILDING = enum.auto() + DESERT_TRANSPORTATION = enum.auto() + ISLAND_TRANSPORTATION = enum.auto() ARCADE_MACHINE_BUFFS = enum.auto() BASE_RESOURCE = enum.auto() WARP_TOTEM = enum.auto() diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py index 0d621fda49..fa4d50ce79 100644 --- a/worlds/stardew_valley/locations.py +++ b/worlds/stardew_valley/locations.py @@ -279,6 +279,9 @@ def extend_festival_locations(randomized_locations: List[LocationData], options: return festival_locations = locations_by_tag[LocationTags.FESTIVAL] + if not options.museumsanity: + festival_locations = [location for location in festival_locations if location.name not in ("Rarecrow #7 (Tanuki)", "Rarecrow #8 (Tribal Mask)")] + randomized_locations.extend(festival_locations) extend_hard_festival_locations(randomized_locations, options) extend_desert_festival_chef_locations(randomized_locations, options, random) diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index 716dd06571..42bfb9cc26 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -168,15 +168,16 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin AnimalProduct.squid_ink: self.mine.can_mine_in_the_mines_floor_81_120() | (self.building.has_building(Building.fish_pond) & self.has(Fish.squid)), AnimalProduct.sturgeon_roe: self.has(Fish.sturgeon) & self.building.has_building(Building.fish_pond), AnimalProduct.truffle: self.animal.has_animal(Animal.pig) & self.season.has_any_not_winter(), - AnimalProduct.void_egg: self.has(AnimalProduct.void_egg_starter), # Should also check void chicken if there was an alternative to obtain it without void egg + AnimalProduct.void_egg: self.has(AnimalProduct.void_egg_starter), # Should also check void chicken if there was an alternative to obtain it without void egg AnimalProduct.wool: self.animal.has_animal(Animal.rabbit) | self.animal.has_animal(Animal.sheep), AnimalProduct.slime_egg_green: self.has(Machine.slime_egg_press) & self.has(Loot.slime), AnimalProduct.slime_egg_blue: self.has(Machine.slime_egg_press) & self.has(Loot.slime) & self.time.has_lived_months(3), AnimalProduct.slime_egg_red: self.has(Machine.slime_egg_press) & self.has(Loot.slime) & self.time.has_lived_months(6), AnimalProduct.slime_egg_purple: self.has(Machine.slime_egg_press) & self.has(Loot.slime) & self.time.has_lived_months(9), - AnimalProduct.slime_egg_tiger: self.has(Fish.lionfish) & self.building.has_building(Building.fish_pond), - AnimalProduct.duck_egg_starter: self.logic.false_, # It could be purchased at the Feast of the Winter Star, but it's random every year, so not considering it yet... - AnimalProduct.dinosaur_egg_starter: self.logic.false_, # Dinosaur eggs are also part of the museum rules, and I don't want to touch them yet. + AnimalProduct.slime_egg_tiger: self.can_fish_pond(Fish.lionfish, *(Forageable.ginger, Fruit.pineapple, Fruit.mango)) & self.time.has_lived_months(12) & + self.building.has_building(Building.slime_hutch) & self.monster.can_kill(Monster.tiger_slime), + AnimalProduct.duck_egg_starter: self.logic.false_, # It could be purchased at the Feast of the Winter Star, but it's random every year, so not considering it yet... + AnimalProduct.dinosaur_egg_starter: self.logic.false_, # Dinosaur eggs are also part of the museum rules, and I don't want to touch them yet. AnimalProduct.egg_starter: self.logic.false_, # It could be purchased at the Desert Festival, but festival logic is quite a mess, so not considering it yet... AnimalProduct.golden_egg_starter: self.received(AnimalProduct.golden_egg) & (self.money.can_spend_at(Region.ranch, 100000) | self.money.can_trade_at(Region.qi_walnut_room, Currency.qi_gem, 100)), AnimalProduct.void_egg_starter: self.money.can_spend_at(Region.sewer, 5000) | (self.building.has_building(Building.fish_pond) & self.has(Fish.void_salmon)), @@ -233,7 +234,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin Forageable.secret_note: self.quest.has_magnifying_glass() & (self.ability.can_chop_trees() | self.mine.can_mine_in_the_mines_floor_1_40()), # Fossil.bone_fragment: (self.region.can_reach(Region.dig_site) & self.tool.has_tool(Tool.pickaxe)) | self.monster.can_kill(Monster.skeleton), Fossil.fossilized_leg: self.region.can_reach(Region.dig_site) & self.tool.has_tool(Tool.pickaxe), - Fossil.fossilized_ribs: self.region.can_reach(Region.island_south) & self.tool.has_tool(Tool.hoe), + Fossil.fossilized_ribs: self.region.can_reach(Region.island_south) & self.tool.has_tool(Tool.hoe) & self.received("Open Professor Snail Cave"), Fossil.fossilized_skull: self.action.can_open_geode(Geode.golden_coconut), Fossil.fossilized_spine: self.fishing.can_fish_at(Region.dig_site), Fossil.fossilized_tail: self.action.can_pan_at(Region.dig_site, ToolMaterial.copper), @@ -288,9 +289,9 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin MetalBar.quartz: self.can_smelt(Mineral.quartz) | self.can_smelt("Fire Quartz") | (self.has(Machine.recycling_machine) & (self.has(Trash.broken_cd) | self.has(Trash.broken_glasses))), MetalBar.radioactive: self.can_smelt(Ore.radioactive), Ore.copper: self.mine.can_mine_in_the_mines_floor_1_40() | self.mine.can_mine_in_the_skull_cavern() | self.tool.has_tool(Tool.pan, ToolMaterial.copper), - Ore.gold: self.mine.can_mine_in_the_mines_floor_81_120() | self.mine.can_mine_in_the_skull_cavern() | self.tool.has_tool(Tool.pan, ToolMaterial.iron), - Ore.iridium: self.mine.can_mine_in_the_skull_cavern() | self.can_fish_pond(Fish.super_cucumber) | self.tool.has_tool(Tool.pan, ToolMaterial.gold), - Ore.iron: self.mine.can_mine_in_the_mines_floor_41_80() | self.mine.can_mine_in_the_skull_cavern() | self.tool.has_tool(Tool.pan, ToolMaterial.copper), + Ore.gold: self.mine.can_mine_in_the_mines_floor_81_120() | self.mine.can_mine_in_the_skull_cavern() | self.tool.has_tool(Tool.pan, ToolMaterial.gold), + Ore.iridium: self.count(2, *(self.mine.can_mine_in_the_skull_cavern(), self.can_fish_pond(Fish.super_cucumber), self.tool.has_tool(Tool.pan, ToolMaterial.iridium))), + Ore.iron: self.mine.can_mine_in_the_mines_floor_41_80() | self.mine.can_mine_in_the_skull_cavern() | self.tool.has_tool(Tool.pan, ToolMaterial.iron), Ore.radioactive: self.ability.can_mine_perfectly() & self.region.can_reach(Region.qi_walnut_room), RetainingSoil.basic: self.money.can_spend_at(Region.pierre_store, 100), RetainingSoil.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150), @@ -381,5 +382,8 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin def can_use_obelisk(self, obelisk: str) -> StardewRule: return self.region.can_reach(Region.farm) & self.received(obelisk) - def can_fish_pond(self, fish: str) -> StardewRule: - return self.building.has_building(Building.fish_pond) & self.has(fish) + def can_fish_pond(self, fish: str, *items: str) -> StardewRule: + rule = self.building.has_building(Building.fish_pond) & self.has(fish) + if items: + rule = rule & self.has_all(*items) + return rule diff --git a/worlds/stardew_valley/logic/museum_logic.py b/worlds/stardew_valley/logic/museum_logic.py index 2237cd89ea..21718db27c 100644 --- a/worlds/stardew_valley/logic/museum_logic.py +++ b/worlds/stardew_valley/logic/museum_logic.py @@ -1,13 +1,5 @@ -from typing import Union - from Utils import cache_self1 -from .action_logic import ActionLogicMixin from .base_logic import BaseLogic, BaseLogicMixin -from .has_logic import HasLogicMixin -from .received_logic import ReceivedLogicMixin -from .region_logic import RegionLogicMixin -from .time_logic import TimeLogicMixin -from .tool_logic import ToolLogicMixin from .. import options from ..data.museum_data import MuseumItem, all_museum_items, all_museum_artifacts, all_museum_minerals from ..stardew_rule import StardewRule, False_ diff --git a/worlds/stardew_valley/logic/quest_logic.py b/worlds/stardew_valley/logic/quest_logic.py index 5bc3f86eae..af52d06e30 100644 --- a/worlds/stardew_valley/logic/quest_logic.py +++ b/worlds/stardew_valley/logic/quest_logic.py @@ -89,7 +89,7 @@ class QuestLogic(BaseLogic): Quest.goblin_problem: self.logic.region.can_reach(Region.witch_swamp) # Void mayo can be fished at 5% chance in the witch swamp while the quest is active. It drops a lot after the quest. & (self.logic.has(ArtisanGood.void_mayonnaise) | self.logic.fishing.can_fish()), - Quest.magic_ink: self.logic.relationship.can_meet(NPC.wizard), + Quest.magic_ink: self.logic.region.can_reach(Region.witch_hut) & self.logic.relationship.can_meet(NPC.wizard), Quest.the_pirates_wife: self.logic.relationship.can_meet(NPC.kent) & self.logic.relationship.can_meet(NPC.gus) & self.logic.relationship.can_meet(NPC.sandy) & self.logic.relationship.can_meet(NPC.george) & self.logic.relationship.can_meet(NPC.wizard) & self.logic.relationship.can_meet(NPC.willy), diff --git a/worlds/stardew_valley/logic/region_logic.py b/worlds/stardew_valley/logic/region_logic.py index 083f56e167..81c79be097 100644 --- a/worlds/stardew_valley/logic/region_logic.py +++ b/worlds/stardew_valley/logic/region_logic.py @@ -1,23 +1,23 @@ -from typing import Tuple, Union +from typing import Tuple from Utils import cache_self1 from .base_logic import BaseLogic, BaseLogicMixin -from .has_logic import HasLogicMixin from ..options import EntranceRandomization from ..stardew_rule import StardewRule, Reach, false_, true_ from ..strings.region_names import Region main_outside_area = {Region.menu, Region.stardew_valley, Region.farm_house, Region.farm, Region.town, Region.beach, Region.mountain, Region.forest, Region.bus_stop, Region.backwoods, Region.bus_tunnel, Region.tunnel_entrance} -always_accessible_regions_without_er = {*main_outside_area, Region.community_center, Region.pantry, Region.crafts_room, Region.fish_tank, Region.boiler_room, - Region.vault, Region.bulletin_board, Region.mines, Region.hospital, Region.carpenter, Region.alex_house, - Region.elliott_house, Region.ranch, Region.farm_cave, Region.wizard_tower, Region.tent, Region.pierre_store, - Region.saloon, Region.blacksmith, Region.trailer, Region.museum, Region.mayor_house, Region.haley_house, - Region.sam_house, Region.jojamart, Region.fish_shop} +always_accessible_regions_with_non_progression_er = {*main_outside_area, Region.mines, Region.hospital, Region.carpenter, Region.alex_house, + Region.ranch, Region.farm_cave, Region.wizard_tower, Region.tent, + Region.pierre_store, Region.saloon, Region.blacksmith, Region.trailer, Region.museum, Region.mayor_house, + Region.haley_house, Region.sam_house, Region.jojamart, Region.fish_shop} +always_accessible_regions_without_er = {*always_accessible_regions_with_non_progression_er, Region.community_center, Region.pantry, Region.crafts_room, + Region.fish_tank, Region.boiler_room, Region.vault, Region.bulletin_board} always_regions_by_setting = {EntranceRandomization.option_disabled: always_accessible_regions_without_er, EntranceRandomization.option_pelican_town: always_accessible_regions_without_er, - EntranceRandomization.option_non_progression: always_accessible_regions_without_er, + EntranceRandomization.option_non_progression: always_accessible_regions_with_non_progression_er, EntranceRandomization.option_buildings_without_house: main_outside_area, EntranceRandomization.option_buildings: main_outside_area, EntranceRandomization.option_chaos: always_accessible_regions_without_er} diff --git a/worlds/stardew_valley/mods/logic/sve_logic.py b/worlds/stardew_valley/mods/logic/sve_logic.py index 7f0c12bc4f..03f1737c59 100644 --- a/worlds/stardew_valley/mods/logic/sve_logic.py +++ b/worlds/stardew_valley/mods/logic/sve_logic.py @@ -1,8 +1,7 @@ -from ..mod_regions import SVERegion from ...logic.base_logic import BaseLogicMixin, BaseLogic from ...strings.ap_names.mods.mod_items import SVELocation, SVERunes, SVEQuestItem from ...strings.quest_names import Quest, ModQuest -from ...strings.region_names import Region +from ...strings.region_names import Region, SVERegion from ...strings.tool_names import Tool, ToolMaterial from ...strings.wallet_item_names import Wallet diff --git a/worlds/stardew_valley/mods/mod_regions.py b/worlds/stardew_valley/mods/region_data.py similarity index 61% rename from worlds/stardew_valley/mods/mod_regions.py rename to worlds/stardew_valley/mods/region_data.py index a402ba6068..5dc4a3dff2 100644 --- a/worlds/stardew_valley/mods/mod_regions.py +++ b/worlds/stardew_valley/mods/region_data.py @@ -1,15 +1,14 @@ -from typing import Dict, List - from .mod_data import ModNames -from ..region_classes import RegionData, ConnectionData, ModificationFlag, RandomizationFlag, ModRegionData +from ..content.mods.sve import SVE_GINGER_ISLAND_PACK +from ..regions.model import RegionData, ConnectionData, MergeFlag, RandomizationFlag, ModRegionsData from ..strings.entrance_names import Entrance, DeepWoodsEntrance, EugeneEntrance, LaceyEntrance, BoardingHouseEntrance, \ JasperEntrance, AlecEntrance, YobaEntrance, JunaEntrance, MagicEntrance, AyeishaEntrance, RileyEntrance, SVEEntrance, AlectoEntrance from ..strings.region_names import Region, DeepWoodsRegion, EugeneRegion, JasperRegion, BoardingHouseRegion, \ AlecRegion, YobaRegion, JunaRegion, MagicRegion, AyeishaRegion, RileyRegion, SVERegion, AlectoRegion, LaceyRegion deep_woods_regions = [ - RegionData(Region.farm, [DeepWoodsEntrance.use_woods_obelisk]), - RegionData(DeepWoodsRegion.woods_obelisk_menu, [DeepWoodsEntrance.deep_woods_depth_1, + RegionData(Region.farm, (DeepWoodsEntrance.use_woods_obelisk,)), + RegionData(DeepWoodsRegion.woods_obelisk_menu, (DeepWoodsEntrance.deep_woods_depth_1, DeepWoodsEntrance.deep_woods_depth_10, DeepWoodsEntrance.deep_woods_depth_20, DeepWoodsEntrance.deep_woods_depth_30, @@ -19,9 +18,9 @@ deep_woods_regions = [ DeepWoodsEntrance.deep_woods_depth_70, DeepWoodsEntrance.deep_woods_depth_80, DeepWoodsEntrance.deep_woods_depth_90, - DeepWoodsEntrance.deep_woods_depth_100]), - RegionData(Region.secret_woods, [DeepWoodsEntrance.secret_woods_to_deep_woods]), - RegionData(DeepWoodsRegion.main_lichtung, [DeepWoodsEntrance.deep_woods_house]), + DeepWoodsEntrance.deep_woods_depth_100)), + RegionData(Region.secret_woods, (DeepWoodsEntrance.secret_woods_to_deep_woods,)), + RegionData(DeepWoodsRegion.main_lichtung, (DeepWoodsEntrance.deep_woods_house,)), RegionData(DeepWoodsRegion.abandoned_home), RegionData(DeepWoodsRegion.floor_10), RegionData(DeepWoodsRegion.floor_20), @@ -32,14 +31,13 @@ deep_woods_regions = [ RegionData(DeepWoodsRegion.floor_70), RegionData(DeepWoodsRegion.floor_80), RegionData(DeepWoodsRegion.floor_90), - RegionData(DeepWoodsRegion.floor_100) + RegionData(DeepWoodsRegion.floor_100), ] deep_woods_entrances = [ ConnectionData(DeepWoodsEntrance.use_woods_obelisk, DeepWoodsRegion.woods_obelisk_menu), ConnectionData(DeepWoodsEntrance.secret_woods_to_deep_woods, DeepWoodsRegion.main_lichtung), - ConnectionData(DeepWoodsEntrance.deep_woods_house, DeepWoodsRegion.abandoned_home, - flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData(DeepWoodsEntrance.deep_woods_house, DeepWoodsRegion.abandoned_home, flag=RandomizationFlag.BUILDINGS), ConnectionData(DeepWoodsEntrance.deep_woods_depth_1, DeepWoodsRegion.main_lichtung), ConnectionData(DeepWoodsEntrance.deep_woods_depth_10, DeepWoodsRegion.floor_10), ConnectionData(DeepWoodsEntrance.deep_woods_depth_20, DeepWoodsRegion.floor_20), @@ -50,165 +48,166 @@ deep_woods_entrances = [ ConnectionData(DeepWoodsEntrance.deep_woods_depth_70, DeepWoodsRegion.floor_70), ConnectionData(DeepWoodsEntrance.deep_woods_depth_80, DeepWoodsRegion.floor_80), ConnectionData(DeepWoodsEntrance.deep_woods_depth_90, DeepWoodsRegion.floor_90), - ConnectionData(DeepWoodsEntrance.deep_woods_depth_100, DeepWoodsRegion.floor_100) + ConnectionData(DeepWoodsEntrance.deep_woods_depth_100, DeepWoodsRegion.floor_100), ] eugene_regions = [ - RegionData(Region.forest, [EugeneEntrance.forest_to_garden]), - RegionData(EugeneRegion.eugene_garden, [EugeneEntrance.garden_to_bedroom]), - RegionData(EugeneRegion.eugene_bedroom) + RegionData(Region.forest, (EugeneEntrance.forest_to_garden,)), + RegionData(EugeneRegion.eugene_garden, (EugeneEntrance.garden_to_bedroom,)), + RegionData(EugeneRegion.eugene_bedroom), ] eugene_entrances = [ ConnectionData(EugeneEntrance.forest_to_garden, EugeneRegion.eugene_garden, flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(EugeneEntrance.garden_to_bedroom, EugeneRegion.eugene_bedroom, flag=RandomizationFlag.BUILDINGS) + ConnectionData(EugeneEntrance.garden_to_bedroom, EugeneRegion.eugene_bedroom, flag=RandomizationFlag.BUILDINGS), ] magic_regions = [ - RegionData(Region.pierre_store, [MagicEntrance.store_to_altar]), - RegionData(MagicRegion.altar) + RegionData(Region.pierre_store, (MagicEntrance.store_to_altar,)), + RegionData(MagicRegion.altar), ] magic_entrances = [ - ConnectionData(MagicEntrance.store_to_altar, MagicRegion.altar, flag=RandomizationFlag.NOT_RANDOMIZED) + ConnectionData(MagicEntrance.store_to_altar, MagicRegion.altar, flag=RandomizationFlag.NOT_RANDOMIZED), ] jasper_regions = [ - RegionData(Region.museum, [JasperEntrance.museum_to_bedroom]), - RegionData(JasperRegion.jasper_bedroom) + RegionData(Region.museum, (JasperEntrance.museum_to_bedroom,)), + RegionData(JasperRegion.jasper_bedroom), ] jasper_entrances = [ - ConnectionData(JasperEntrance.museum_to_bedroom, JasperRegion.jasper_bedroom, flag=RandomizationFlag.BUILDINGS) + ConnectionData(JasperEntrance.museum_to_bedroom, JasperRegion.jasper_bedroom, flag=RandomizationFlag.BUILDINGS), ] alec_regions = [ - RegionData(Region.forest, [AlecEntrance.forest_to_petshop]), - RegionData(AlecRegion.pet_store, [AlecEntrance.petshop_to_bedroom]), - RegionData(AlecRegion.alec_bedroom) + RegionData(Region.forest, (AlecEntrance.forest_to_petshop,)), + RegionData(AlecRegion.pet_store, (AlecEntrance.petshop_to_bedroom,)), + RegionData(AlecRegion.alec_bedroom), ] alec_entrances = [ ConnectionData(AlecEntrance.forest_to_petshop, AlecRegion.pet_store, flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(AlecEntrance.petshop_to_bedroom, AlecRegion.alec_bedroom, flag=RandomizationFlag.BUILDINGS) + ConnectionData(AlecEntrance.petshop_to_bedroom, AlecRegion.alec_bedroom, flag=RandomizationFlag.BUILDINGS), ] yoba_regions = [ - RegionData(Region.secret_woods, [YobaEntrance.secret_woods_to_clearing]), - RegionData(YobaRegion.yoba_clearing) + RegionData(Region.secret_woods, (YobaEntrance.secret_woods_to_clearing,)), + RegionData(YobaRegion.yoba_clearing), ] yoba_entrances = [ - ConnectionData(YobaEntrance.secret_woods_to_clearing, YobaRegion.yoba_clearing, flag=RandomizationFlag.BUILDINGS) + ConnectionData(YobaEntrance.secret_woods_to_clearing, YobaRegion.yoba_clearing, flag=RandomizationFlag.BUILDINGS), ] juna_regions = [ - RegionData(Region.forest, [JunaEntrance.forest_to_juna_cave]), - RegionData(JunaRegion.juna_cave) + RegionData(Region.forest, (JunaEntrance.forest_to_juna_cave,)), + RegionData(JunaRegion.juna_cave), ] juna_entrances = [ ConnectionData(JunaEntrance.forest_to_juna_cave, JunaRegion.juna_cave, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA) + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), ] ayeisha_regions = [ - RegionData(Region.bus_stop, [AyeishaEntrance.bus_stop_to_mail_van]), - RegionData(AyeishaRegion.mail_van) + RegionData(Region.bus_stop, (AyeishaEntrance.bus_stop_to_mail_van,)), + RegionData(AyeishaRegion.mail_van), ] ayeisha_entrances = [ ConnectionData(AyeishaEntrance.bus_stop_to_mail_van, AyeishaRegion.mail_van, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA) + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), ] riley_regions = [ - RegionData(Region.town, [RileyEntrance.town_to_riley]), - RegionData(RileyRegion.riley_house) + RegionData(Region.town, (RileyEntrance.town_to_riley,)), + RegionData(RileyRegion.riley_house), ] riley_entrances = [ ConnectionData(RileyEntrance.town_to_riley, RileyRegion.riley_house, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA) + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), ] -stardew_valley_expanded_regions = [ - RegionData(Region.backwoods, [SVEEntrance.backwoods_to_grove]), - RegionData(SVERegion.enchanted_grove, [SVEEntrance.grove_to_outpost_warp, SVEEntrance.grove_to_wizard_warp, +sve_main_land_regions = [ + RegionData(Region.backwoods, (SVEEntrance.backwoods_to_grove,)), + RegionData(SVERegion.enchanted_grove, (SVEEntrance.grove_to_outpost_warp, SVEEntrance.grove_to_wizard_warp, SVEEntrance.grove_to_farm_warp, SVEEntrance.grove_to_guild_warp, SVEEntrance.grove_to_junimo_warp, - SVEEntrance.grove_to_spring_warp, SVEEntrance.grove_to_aurora_warp]), - RegionData(SVERegion.grove_farm_warp, [SVEEntrance.farm_warp_to_farm]), - RegionData(SVERegion.grove_aurora_warp, [SVEEntrance.aurora_warp_to_aurora]), - RegionData(SVERegion.grove_guild_warp, [SVEEntrance.guild_warp_to_guild]), - RegionData(SVERegion.grove_junimo_warp, [SVEEntrance.junimo_warp_to_junimo]), - RegionData(SVERegion.grove_spring_warp, [SVEEntrance.spring_warp_to_spring]), - RegionData(SVERegion.grove_outpost_warp, [SVEEntrance.outpost_warp_to_outpost]), - RegionData(SVERegion.grove_wizard_warp, [SVEEntrance.wizard_warp_to_wizard]), - RegionData(SVERegion.galmoran_outpost, [SVEEntrance.outpost_to_badlands_entrance, SVEEntrance.use_alesia_shop, - SVEEntrance.use_isaac_shop]), - RegionData(SVERegion.badlands_entrance, [SVEEntrance.badlands_entrance_to_badlands]), - RegionData(SVERegion.crimson_badlands, [SVEEntrance.badlands_to_cave]), + SVEEntrance.grove_to_spring_warp, SVEEntrance.grove_to_aurora_warp)), + RegionData(SVERegion.grove_farm_warp, (SVEEntrance.farm_warp_to_farm,)), + RegionData(SVERegion.grove_aurora_warp, (SVEEntrance.aurora_warp_to_aurora,)), + RegionData(SVERegion.grove_guild_warp, (SVEEntrance.guild_warp_to_guild,)), + RegionData(SVERegion.grove_junimo_warp, (SVEEntrance.junimo_warp_to_junimo,)), + RegionData(SVERegion.grove_spring_warp, (SVEEntrance.spring_warp_to_spring,)), + RegionData(SVERegion.grove_outpost_warp, (SVEEntrance.outpost_warp_to_outpost,)), + RegionData(SVERegion.grove_wizard_warp, (SVEEntrance.wizard_warp_to_wizard,)), + RegionData(SVERegion.galmoran_outpost, (SVEEntrance.outpost_to_badlands_entrance, SVEEntrance.use_alesia_shop, SVEEntrance.use_isaac_shop)), + RegionData(SVERegion.badlands_entrance, (SVEEntrance.badlands_entrance_to_badlands,)), + RegionData(SVERegion.crimson_badlands, (SVEEntrance.badlands_to_cave,)), RegionData(SVERegion.badlands_cave), - RegionData(Region.bus_stop, [SVEEntrance.bus_stop_to_shed]), - RegionData(SVERegion.grandpas_shed, [SVEEntrance.grandpa_shed_to_interior, SVEEntrance.grandpa_shed_to_town]), - RegionData(SVERegion.grandpas_shed_interior, [SVEEntrance.grandpa_interior_to_upstairs]), + RegionData(Region.bus_stop, (SVEEntrance.bus_stop_to_shed,)), + RegionData(SVERegion.grandpas_shed, (SVEEntrance.grandpa_shed_to_interior, SVEEntrance.grandpa_shed_to_town)), + RegionData(SVERegion.grandpas_shed_interior, (SVEEntrance.grandpa_interior_to_upstairs,)), RegionData(SVERegion.grandpas_shed_upstairs), RegionData(Region.forest, - [SVEEntrance.forest_to_fairhaven, SVEEntrance.forest_to_west, SVEEntrance.forest_to_lost_woods, - SVEEntrance.forest_to_bmv, SVEEntrance.forest_to_marnie_shed]), + (SVEEntrance.forest_to_fairhaven, SVEEntrance.forest_to_west, SVEEntrance.forest_to_lost_woods, + SVEEntrance.forest_to_bmv, SVEEntrance.forest_to_marnie_shed)), RegionData(SVERegion.marnies_shed), RegionData(SVERegion.fairhaven_farm), - RegionData(Region.town, [SVEEntrance.town_to_bmv, SVEEntrance.town_to_jenkins, - SVEEntrance.town_to_bridge, SVEEntrance.town_to_plot]), - RegionData(SVERegion.blue_moon_vineyard, [SVEEntrance.bmv_to_sophia, SVEEntrance.bmv_to_beach]), + RegionData(Region.town, (SVEEntrance.town_to_bmv, SVEEntrance.town_to_jenkins, SVEEntrance.town_to_bridge, SVEEntrance.town_to_plot)), + RegionData(SVERegion.blue_moon_vineyard, (SVEEntrance.bmv_to_sophia, SVEEntrance.bmv_to_beach)), RegionData(SVERegion.sophias_house), - RegionData(SVERegion.jenkins_residence, [SVEEntrance.jenkins_to_cellar]), + RegionData(SVERegion.jenkins_residence, (SVEEntrance.jenkins_to_cellar,)), RegionData(SVERegion.jenkins_cellar), - RegionData(SVERegion.unclaimed_plot, [SVEEntrance.plot_to_bridge]), + RegionData(SVERegion.unclaimed_plot, (SVEEntrance.plot_to_bridge,)), RegionData(SVERegion.shearwater), - RegionData(Region.museum, [SVEEntrance.museum_to_gunther_bedroom]), + RegionData(Region.museum, (SVEEntrance.museum_to_gunther_bedroom,)), RegionData(SVERegion.gunther_bedroom), - RegionData(Region.fish_shop, [SVEEntrance.fish_shop_to_willy_bedroom]), + RegionData(Region.fish_shop, (SVEEntrance.fish_shop_to_willy_bedroom,)), RegionData(SVERegion.willy_bedroom), - RegionData(Region.mountain, [SVEEntrance.mountain_to_guild_summit]), - RegionData(SVERegion.guild_summit, [SVEEntrance.guild_to_interior, SVEEntrance.guild_to_mines, - SVEEntrance.summit_to_highlands]), - RegionData(Region.railroad, [SVEEntrance.to_susan_house, SVEEntrance.enter_summit, SVEEntrance.railroad_to_grampleton_station]), - RegionData(SVERegion.grampleton_station, [SVEEntrance.grampleton_station_to_grampleton_suburbs]), - RegionData(SVERegion.grampleton_suburbs, [SVEEntrance.grampleton_suburbs_to_scarlett_house]), + RegionData(Region.mountain, (SVEEntrance.mountain_to_guild_summit,)), + # These entrances are removed from the mountain region when SVE is enabled + RegionData(Region.mountain, (Entrance.mountain_to_adventurer_guild, Entrance.mountain_to_the_mines), flag=MergeFlag.REMOVE_EXITS), + RegionData(SVERegion.guild_summit, (SVEEntrance.guild_to_interior, SVEEntrance.guild_to_mines)), + RegionData(Region.railroad, (SVEEntrance.to_susan_house, SVEEntrance.enter_summit, SVEEntrance.railroad_to_grampleton_station)), + RegionData(SVERegion.grampleton_station, (SVEEntrance.grampleton_station_to_grampleton_suburbs,)), + RegionData(SVERegion.grampleton_suburbs, (SVEEntrance.grampleton_suburbs_to_scarlett_house,)), RegionData(SVERegion.scarlett_house), - RegionData(Region.wizard_basement, [SVEEntrance.wizard_to_fable_reef]), - RegionData(SVERegion.fable_reef, [SVEEntrance.fable_reef_to_guild], is_ginger_island=True), - RegionData(SVERegion.first_slash_guild, [SVEEntrance.first_slash_guild_to_hallway], is_ginger_island=True), - RegionData(SVERegion.first_slash_hallway, [SVEEntrance.first_slash_hallway_to_room], is_ginger_island=True), - RegionData(SVERegion.first_slash_spare_room, is_ginger_island=True), - RegionData(SVERegion.highlands_outside, [SVEEntrance.highlands_to_lance, SVEEntrance.highlands_to_cave, SVEEntrance.highlands_to_pond], is_ginger_island=True), - RegionData(SVERegion.highlands_pond, is_ginger_island=True), - RegionData(SVERegion.highlands_cavern, [SVEEntrance.to_dwarf_prison], is_ginger_island=True), - RegionData(SVERegion.dwarf_prison, is_ginger_island=True), - RegionData(SVERegion.lances_house, [SVEEntrance.lance_to_ladder], is_ginger_island=True), - RegionData(SVERegion.lances_ladder, [SVEEntrance.lance_ladder_to_highlands], is_ginger_island=True), - RegionData(SVERegion.forest_west, [SVEEntrance.forest_west_to_spring, SVEEntrance.west_to_aurora, - SVEEntrance.use_bear_shop]), - RegionData(SVERegion.aurora_vineyard, [SVEEntrance.to_aurora_basement]), + RegionData(SVERegion.forest_west, (SVEEntrance.forest_west_to_spring, SVEEntrance.west_to_aurora, SVEEntrance.use_bear_shop,)), + RegionData(SVERegion.aurora_vineyard, (SVEEntrance.to_aurora_basement,)), RegionData(SVERegion.aurora_vineyard_basement), - RegionData(Region.secret_woods, [SVEEntrance.secret_woods_to_west]), + RegionData(Region.secret_woods, (SVEEntrance.secret_woods_to_west,)), RegionData(SVERegion.bear_shop), - RegionData(SVERegion.sprite_spring, [SVEEntrance.sprite_spring_to_cave]), + RegionData(SVERegion.sprite_spring, (SVEEntrance.sprite_spring_to_cave,)), RegionData(SVERegion.sprite_spring_cave), - RegionData(SVERegion.lost_woods, [SVEEntrance.lost_woods_to_junimo_woods]), - RegionData(SVERegion.junimo_woods, [SVEEntrance.use_purple_junimo]), + RegionData(SVERegion.lost_woods, (SVEEntrance.lost_woods_to_junimo_woods,)), + RegionData(SVERegion.junimo_woods, (SVEEntrance.use_purple_junimo,)), RegionData(SVERegion.purple_junimo_shop), RegionData(SVERegion.alesia_shop), RegionData(SVERegion.isaac_shop), RegionData(SVERegion.summit), RegionData(SVERegion.susans_house), - RegionData(Region.mountain, [Entrance.mountain_to_adventurer_guild, Entrance.mountain_to_the_mines], ModificationFlag.MODIFIED) - ] -mandatory_sve_connections = [ +sve_ginger_island_regions = [ + RegionData(Region.wizard_basement, (SVEEntrance.wizard_to_fable_reef,)), + + RegionData(SVERegion.fable_reef, (SVEEntrance.fable_reef_to_guild,)), + RegionData(SVERegion.first_slash_guild, (SVEEntrance.first_slash_guild_to_hallway,)), + RegionData(SVERegion.first_slash_hallway, (SVEEntrance.first_slash_hallway_to_room,)), + RegionData(SVERegion.first_slash_spare_room), + RegionData(SVERegion.guild_summit, (SVEEntrance.summit_to_highlands,)), + RegionData(SVERegion.highlands_outside, (SVEEntrance.highlands_to_lance, SVEEntrance.highlands_to_cave, SVEEntrance.highlands_to_pond), ), + RegionData(SVERegion.highlands_pond), + RegionData(SVERegion.highlands_cavern, (SVEEntrance.to_dwarf_prison,)), + RegionData(SVERegion.dwarf_prison), + RegionData(SVERegion.lances_house, (SVEEntrance.lance_to_ladder,)), + RegionData(SVERegion.lances_ladder, (SVEEntrance.lance_ladder_to_highlands,)), +] + +sve_main_land_connections = [ ConnectionData(SVEEntrance.town_to_jenkins, SVERegion.jenkins_residence, flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), ConnectionData(SVEEntrance.jenkins_to_cellar, SVERegion.jenkins_cellar, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.forest_to_bmv, SVERegion.blue_moon_vineyard), @@ -223,7 +222,7 @@ mandatory_sve_connections = [ ConnectionData(SVEEntrance.grandpa_interior_to_upstairs, SVERegion.grandpas_shed_upstairs, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.grandpa_shed_to_town, Region.town), ConnectionData(SVEEntrance.bmv_to_sophia, SVERegion.sophias_house, flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(SVEEntrance.summit_to_highlands, SVERegion.highlands_outside, flag=RandomizationFlag.GINGER_ISLAND), + ConnectionData(SVEEntrance.summit_to_highlands, SVERegion.highlands_outside), ConnectionData(SVEEntrance.guild_to_interior, Region.adventurer_guild, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.backwoods_to_grove, SVERegion.enchanted_grove, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), ConnectionData(SVEEntrance.grove_to_outpost_warp, SVERegion.grove_outpost_warp), @@ -242,8 +241,6 @@ mandatory_sve_connections = [ ConnectionData(SVEEntrance.use_purple_junimo, SVERegion.purple_junimo_shop), ConnectionData(SVEEntrance.grove_to_spring_warp, SVERegion.grove_spring_warp), ConnectionData(SVEEntrance.spring_warp_to_spring, SVERegion.sprite_spring, flag=RandomizationFlag.BUILDINGS), - ConnectionData(SVEEntrance.wizard_to_fable_reef, SVERegion.fable_reef, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(SVEEntrance.fable_reef_to_guild, SVERegion.first_slash_guild, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), ConnectionData(SVEEntrance.outpost_to_badlands_entrance, SVERegion.badlands_entrance, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.badlands_entrance_to_badlands, SVERegion.crimson_badlands, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.badlands_to_cave, SVERegion.badlands_cave, flag=RandomizationFlag.BUILDINGS), @@ -259,71 +256,75 @@ mandatory_sve_connections = [ ConnectionData(SVEEntrance.to_susan_house, SVERegion.susans_house, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.enter_summit, SVERegion.summit, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.forest_to_fairhaven, SVERegion.fairhaven_farm, flag=RandomizationFlag.NON_PROGRESSION), - ConnectionData(SVEEntrance.highlands_to_lance, SVERegion.lances_house, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(SVEEntrance.lance_to_ladder, SVERegion.lances_ladder, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(SVEEntrance.lance_ladder_to_highlands, SVERegion.highlands_outside, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(SVEEntrance.highlands_to_cave, SVERegion.highlands_cavern, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), ConnectionData(SVEEntrance.use_bear_shop, SVERegion.bear_shop), ConnectionData(SVEEntrance.use_purple_junimo, SVERegion.purple_junimo_shop), ConnectionData(SVEEntrance.use_alesia_shop, SVERegion.alesia_shop), ConnectionData(SVEEntrance.use_isaac_shop, SVERegion.isaac_shop), - ConnectionData(SVEEntrance.to_dwarf_prison, SVERegion.dwarf_prison, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), ConnectionData(SVEEntrance.railroad_to_grampleton_station, SVERegion.grampleton_station), ConnectionData(SVEEntrance.grampleton_station_to_grampleton_suburbs, SVERegion.grampleton_suburbs), ConnectionData(SVEEntrance.grampleton_suburbs_to_scarlett_house, SVERegion.scarlett_house, flag=RandomizationFlag.BUILDINGS), - ConnectionData(SVEEntrance.first_slash_guild_to_hallway, SVERegion.first_slash_hallway, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(SVEEntrance.first_slash_hallway_to_room, SVERegion.first_slash_spare_room, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), ConnectionData(SVEEntrance.sprite_spring_to_cave, SVERegion.sprite_spring_cave, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.fish_shop_to_willy_bedroom, SVERegion.willy_bedroom, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.museum_to_gunther_bedroom, SVERegion.gunther_bedroom, flag=RandomizationFlag.BUILDINGS), ConnectionData(SVEEntrance.highlands_to_pond, SVERegion.highlands_pond), ] +sve_ginger_island_connections = [ + ConnectionData(SVEEntrance.wizard_to_fable_reef, SVERegion.fable_reef, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.fable_reef_to_guild, SVERegion.first_slash_guild, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.highlands_to_lance, SVERegion.lances_house, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.lance_to_ladder, SVERegion.lances_ladder), + ConnectionData(SVEEntrance.lance_ladder_to_highlands, SVERegion.highlands_outside, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.highlands_to_cave, SVERegion.highlands_cavern, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.to_dwarf_prison, SVERegion.dwarf_prison, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.first_slash_guild_to_hallway, SVERegion.first_slash_hallway, flag=RandomizationFlag.BUILDINGS), + ConnectionData(SVEEntrance.first_slash_hallway_to_room, SVERegion.first_slash_spare_room, flag=RandomizationFlag.BUILDINGS), +] + alecto_regions = [ - RegionData(Region.witch_hut, [AlectoEntrance.witch_hut_to_witch_attic]), - RegionData(AlectoRegion.witch_attic) + RegionData(Region.witch_hut, (AlectoEntrance.witch_hut_to_witch_attic,)), + RegionData(AlectoRegion.witch_attic), ] alecto_entrances = [ - ConnectionData(AlectoEntrance.witch_hut_to_witch_attic, AlectoRegion.witch_attic, flag=RandomizationFlag.BUILDINGS) + ConnectionData(AlectoEntrance.witch_hut_to_witch_attic, AlectoRegion.witch_attic, flag=RandomizationFlag.BUILDINGS), ] lacey_regions = [ - RegionData(Region.forest, [LaceyEntrance.forest_to_hat_house]), - RegionData(LaceyRegion.hat_house) + RegionData(Region.forest, (LaceyEntrance.forest_to_hat_house,)), + RegionData(LaceyRegion.hat_house), ] lacey_entrances = [ - ConnectionData(LaceyEntrance.forest_to_hat_house, LaceyRegion.hat_house, flag=RandomizationFlag.BUILDINGS) + ConnectionData(LaceyEntrance.forest_to_hat_house, LaceyRegion.hat_house, flag=RandomizationFlag.BUILDINGS), ] boarding_house_regions = [ - RegionData(Region.bus_stop, [BoardingHouseEntrance.bus_stop_to_boarding_house_plateau]), - RegionData(BoardingHouseRegion.boarding_house_plateau, [BoardingHouseEntrance.boarding_house_plateau_to_boarding_house_first, + RegionData(Region.bus_stop, (BoardingHouseEntrance.bus_stop_to_boarding_house_plateau,)), + RegionData(BoardingHouseRegion.boarding_house_plateau, (BoardingHouseEntrance.boarding_house_plateau_to_boarding_house_first, BoardingHouseEntrance.boarding_house_plateau_to_buffalo_ranch, - BoardingHouseEntrance.boarding_house_plateau_to_abandoned_mines_entrance]), - RegionData(BoardingHouseRegion.boarding_house_first, [BoardingHouseEntrance.boarding_house_first_to_boarding_house_second]), + BoardingHouseEntrance.boarding_house_plateau_to_abandoned_mines_entrance)), + RegionData(BoardingHouseRegion.boarding_house_first, (BoardingHouseEntrance.boarding_house_first_to_boarding_house_second,)), RegionData(BoardingHouseRegion.boarding_house_second), RegionData(BoardingHouseRegion.buffalo_ranch), - RegionData(BoardingHouseRegion.abandoned_mines_entrance, [BoardingHouseEntrance.abandoned_mines_entrance_to_abandoned_mines_1a, - BoardingHouseEntrance.abandoned_mines_entrance_to_the_lost_valley]), - RegionData(BoardingHouseRegion.abandoned_mines_1a, [BoardingHouseEntrance.abandoned_mines_1a_to_abandoned_mines_1b]), - RegionData(BoardingHouseRegion.abandoned_mines_1b, [BoardingHouseEntrance.abandoned_mines_1b_to_abandoned_mines_2a]), - RegionData(BoardingHouseRegion.abandoned_mines_2a, [BoardingHouseEntrance.abandoned_mines_2a_to_abandoned_mines_2b]), - RegionData(BoardingHouseRegion.abandoned_mines_2b, [BoardingHouseEntrance.abandoned_mines_2b_to_abandoned_mines_3]), - RegionData(BoardingHouseRegion.abandoned_mines_3, [BoardingHouseEntrance.abandoned_mines_3_to_abandoned_mines_4]), - RegionData(BoardingHouseRegion.abandoned_mines_4, [BoardingHouseEntrance.abandoned_mines_4_to_abandoned_mines_5]), - RegionData(BoardingHouseRegion.abandoned_mines_5, [BoardingHouseEntrance.abandoned_mines_5_to_the_lost_valley]), - RegionData(BoardingHouseRegion.the_lost_valley, [BoardingHouseEntrance.the_lost_valley_to_gregory_tent, + RegionData(BoardingHouseRegion.abandoned_mines_entrance, (BoardingHouseEntrance.abandoned_mines_entrance_to_abandoned_mines_1a, + BoardingHouseEntrance.abandoned_mines_entrance_to_the_lost_valley)), + RegionData(BoardingHouseRegion.abandoned_mines_1a, (BoardingHouseEntrance.abandoned_mines_1a_to_abandoned_mines_1b,)), + RegionData(BoardingHouseRegion.abandoned_mines_1b, (BoardingHouseEntrance.abandoned_mines_1b_to_abandoned_mines_2a,)), + RegionData(BoardingHouseRegion.abandoned_mines_2a, (BoardingHouseEntrance.abandoned_mines_2a_to_abandoned_mines_2b,)), + RegionData(BoardingHouseRegion.abandoned_mines_2b, (BoardingHouseEntrance.abandoned_mines_2b_to_abandoned_mines_3,)), + RegionData(BoardingHouseRegion.abandoned_mines_3, (BoardingHouseEntrance.abandoned_mines_3_to_abandoned_mines_4,)), + RegionData(BoardingHouseRegion.abandoned_mines_4, (BoardingHouseEntrance.abandoned_mines_4_to_abandoned_mines_5,)), + RegionData(BoardingHouseRegion.abandoned_mines_5, (BoardingHouseEntrance.abandoned_mines_5_to_the_lost_valley,)), + RegionData(BoardingHouseRegion.the_lost_valley, (BoardingHouseEntrance.the_lost_valley_to_gregory_tent, BoardingHouseEntrance.lost_valley_to_lost_valley_minecart, - BoardingHouseEntrance.the_lost_valley_to_lost_valley_ruins]), + BoardingHouseEntrance.the_lost_valley_to_lost_valley_ruins)), RegionData(BoardingHouseRegion.gregory_tent), - RegionData(BoardingHouseRegion.lost_valley_ruins, [BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_1, - BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_2]), + RegionData(BoardingHouseRegion.lost_valley_ruins, (BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_1, + BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_2)), RegionData(BoardingHouseRegion.lost_valley_minecart), RegionData(BoardingHouseRegion.lost_valley_house_1), - RegionData(BoardingHouseRegion.lost_valley_house_2) + RegionData(BoardingHouseRegion.lost_valley_house_2), ] boarding_house_entrances = [ @@ -351,30 +352,29 @@ boarding_house_entrances = [ ConnectionData(BoardingHouseEntrance.lost_valley_to_lost_valley_minecart, BoardingHouseRegion.lost_valley_minecart), ConnectionData(BoardingHouseEntrance.the_lost_valley_to_lost_valley_ruins, BoardingHouseRegion.lost_valley_ruins, flag=RandomizationFlag.BUILDINGS), ConnectionData(BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_1, BoardingHouseRegion.lost_valley_house_1, flag=RandomizationFlag.BUILDINGS), - ConnectionData(BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_2, BoardingHouseRegion.lost_valley_house_2, flag=RandomizationFlag.BUILDINGS) + ConnectionData(BoardingHouseEntrance.lost_valley_ruins_to_lost_valley_house_2, BoardingHouseRegion.lost_valley_house_2, flag=RandomizationFlag.BUILDINGS), ] -vanilla_connections_to_remove_by_mod: Dict[str, List[ConnectionData]] = { - ModNames.sve: [ - ConnectionData(Entrance.mountain_to_the_mines, Region.mines, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.mountain_to_adventurer_guild, Region.adventurer_guild, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ] +vanilla_connections_to_remove_by_content_pack: dict[str, tuple[str, ...]] = { + ModNames.sve: ( + Entrance.mountain_to_the_mines, + Entrance.mountain_to_adventurer_guild, + ) } -ModDataList = { - ModNames.deepwoods: ModRegionData(ModNames.deepwoods, deep_woods_regions, deep_woods_entrances), - ModNames.eugene: ModRegionData(ModNames.eugene, eugene_regions, eugene_entrances), - ModNames.jasper: ModRegionData(ModNames.jasper, jasper_regions, jasper_entrances), - ModNames.alec: ModRegionData(ModNames.alec, alec_regions, alec_entrances), - ModNames.yoba: ModRegionData(ModNames.yoba, yoba_regions, yoba_entrances), - ModNames.juna: ModRegionData(ModNames.juna, juna_regions, juna_entrances), - ModNames.magic: ModRegionData(ModNames.magic, magic_regions, magic_entrances), - ModNames.ayeisha: ModRegionData(ModNames.ayeisha, ayeisha_regions, ayeisha_entrances), - ModNames.riley: ModRegionData(ModNames.riley, riley_regions, riley_entrances), - ModNames.sve: ModRegionData(ModNames.sve, stardew_valley_expanded_regions, mandatory_sve_connections), - ModNames.alecto: ModRegionData(ModNames.alecto, alecto_regions, alecto_entrances), - ModNames.lacey: ModRegionData(ModNames.lacey, lacey_regions, lacey_entrances), - ModNames.boarding_house: ModRegionData(ModNames.boarding_house, boarding_house_regions, boarding_house_entrances), +region_data_by_content_pack = { + ModNames.deepwoods: ModRegionsData(ModNames.deepwoods, deep_woods_regions, deep_woods_entrances), + ModNames.eugene: ModRegionsData(ModNames.eugene, eugene_regions, eugene_entrances), + ModNames.jasper: ModRegionsData(ModNames.jasper, jasper_regions, jasper_entrances), + ModNames.alec: ModRegionsData(ModNames.alec, alec_regions, alec_entrances), + ModNames.yoba: ModRegionsData(ModNames.yoba, yoba_regions, yoba_entrances), + ModNames.juna: ModRegionsData(ModNames.juna, juna_regions, juna_entrances), + ModNames.magic: ModRegionsData(ModNames.magic, magic_regions, magic_entrances), + ModNames.ayeisha: ModRegionsData(ModNames.ayeisha, ayeisha_regions, ayeisha_entrances), + ModNames.riley: ModRegionsData(ModNames.riley, riley_regions, riley_entrances), + ModNames.sve: ModRegionsData(ModNames.sve, sve_main_land_regions, sve_main_land_connections), + SVE_GINGER_ISLAND_PACK: ModRegionsData(SVE_GINGER_ISLAND_PACK, sve_ginger_island_regions, sve_ginger_island_connections), + ModNames.alecto: ModRegionsData(ModNames.alecto, alecto_regions, alecto_entrances), + ModNames.lacey: ModRegionsData(ModNames.lacey, lacey_regions, lacey_entrances), + ModNames.boarding_house: ModRegionsData(ModNames.boarding_house, boarding_house_regions, boarding_house_entrances), } diff --git a/worlds/stardew_valley/region_classes.py b/worlds/stardew_valley/region_classes.py deleted file mode 100644 index d3d16e3878..0000000000 --- a/worlds/stardew_valley/region_classes.py +++ /dev/null @@ -1,67 +0,0 @@ -from copy import deepcopy -from dataclasses import dataclass, field -from enum import IntFlag -from typing import Optional, List, Set - -connector_keyword = " to " - - -class ModificationFlag(IntFlag): - NOT_MODIFIED = 0 - MODIFIED = 1 - - -class RandomizationFlag(IntFlag): - NOT_RANDOMIZED = 0b0 - PELICAN_TOWN = 0b00011111 - NON_PROGRESSION = 0b00011110 - BUILDINGS = 0b00011100 - EVERYTHING = 0b00011000 - GINGER_ISLAND = 0b00100000 - LEAD_TO_OPEN_AREA = 0b01000000 - MASTERIES = 0b10000000 - - -@dataclass(frozen=True) -class RegionData: - name: str - exits: List[str] = field(default_factory=list) - flag: ModificationFlag = ModificationFlag.NOT_MODIFIED - is_ginger_island: bool = False - - def get_merged_with(self, exits: List[str]): - merged_exits = [] - merged_exits.extend(self.exits) - if exits is not None: - merged_exits.extend(exits) - merged_exits = sorted(set(merged_exits)) - return RegionData(self.name, merged_exits, is_ginger_island=self.is_ginger_island) - - def get_without_exits(self, exits_to_remove: Set[str]): - exits = [exit_ for exit_ in self.exits if exit_ not in exits_to_remove] - return RegionData(self.name, exits, is_ginger_island=self.is_ginger_island) - - def get_clone(self): - return deepcopy(self) - - -@dataclass(frozen=True) -class ConnectionData: - name: str - destination: str - origin: Optional[str] = None - reverse: Optional[str] = None - flag: RandomizationFlag = RandomizationFlag.NOT_RANDOMIZED - - def __post_init__(self): - if connector_keyword in self.name: - origin, destination = self.name.split(connector_keyword) - if self.reverse is None: - super().__setattr__("reverse", f"{destination}{connector_keyword}{origin}") - - -@dataclass(frozen=True) -class ModRegionData: - mod_name: str - regions: List[RegionData] - connections: List[ConnectionData] diff --git a/worlds/stardew_valley/regions.py b/worlds/stardew_valley/regions.py deleted file mode 100644 index 4d06d598d3..0000000000 --- a/worlds/stardew_valley/regions.py +++ /dev/null @@ -1,775 +0,0 @@ -from random import Random -from typing import Iterable, Dict, Protocol, List, Tuple, Set - -from BaseClasses import Region, Entrance -from .content import content_packs, StardewContent -from .mods.mod_regions import ModDataList, vanilla_connections_to_remove_by_mod -from .options import EntranceRandomization, ExcludeGingerIsland, StardewValleyOptions -from .region_classes import RegionData, ConnectionData, RandomizationFlag, ModificationFlag -from .strings.entrance_names import Entrance, LogicEntrance -from .strings.region_names import Region as RegionName, LogicRegion - - -class RegionFactory(Protocol): - def __call__(self, name: str, regions: Iterable[str]) -> Region: - raise NotImplementedError - - -vanilla_regions = [ - RegionData(RegionName.menu, [Entrance.to_stardew_valley]), - RegionData(RegionName.stardew_valley, [Entrance.to_farmhouse]), - RegionData(RegionName.farm_house, - [Entrance.farmhouse_to_farm, Entrance.downstairs_to_cellar, LogicEntrance.farmhouse_cooking, LogicEntrance.watch_queen_of_sauce]), - RegionData(RegionName.cellar), - RegionData(RegionName.farm, - [Entrance.farm_to_backwoods, Entrance.farm_to_bus_stop, Entrance.farm_to_forest, Entrance.farm_to_farmcave, Entrance.enter_greenhouse, - Entrance.enter_coop, Entrance.enter_barn, Entrance.enter_shed, Entrance.enter_slime_hutch, LogicEntrance.grow_spring_crops, - LogicEntrance.grow_summer_crops, LogicEntrance.grow_fall_crops, LogicEntrance.grow_winter_crops, LogicEntrance.shipping, - LogicEntrance.fishing, ]), - RegionData(RegionName.backwoods, [Entrance.backwoods_to_mountain]), - RegionData(RegionName.bus_stop, - [Entrance.bus_stop_to_town, Entrance.take_bus_to_desert, Entrance.bus_stop_to_tunnel_entrance]), - RegionData(RegionName.forest, - [Entrance.forest_to_town, Entrance.enter_secret_woods, Entrance.forest_to_wizard_tower, Entrance.forest_to_marnie_ranch, - Entrance.forest_to_leah_cottage, Entrance.forest_to_sewer, Entrance.forest_to_mastery_cave, LogicEntrance.buy_from_traveling_merchant, - LogicEntrance.complete_raccoon_requests, LogicEntrance.fish_in_waterfall, LogicEntrance.attend_flower_dance, LogicEntrance.attend_trout_derby, - LogicEntrance.attend_festival_of_ice]), - RegionData(LogicRegion.forest_waterfall), - RegionData(RegionName.farm_cave), - RegionData(RegionName.greenhouse, - [LogicEntrance.grow_spring_crops_in_greenhouse, LogicEntrance.grow_summer_crops_in_greenhouse, LogicEntrance.grow_fall_crops_in_greenhouse, - LogicEntrance.grow_winter_crops_in_greenhouse, LogicEntrance.grow_indoor_crops_in_greenhouse]), - RegionData(RegionName.mountain, - [Entrance.mountain_to_railroad, Entrance.mountain_to_tent, Entrance.mountain_to_carpenter_shop, - Entrance.mountain_to_the_mines, Entrance.enter_quarry, Entrance.mountain_to_adventurer_guild, - Entrance.mountain_to_town, Entrance.mountain_to_maru_room, - Entrance.mountain_to_leo_treehouse]), - RegionData(RegionName.leo_treehouse, is_ginger_island=True), - RegionData(RegionName.maru_room), - RegionData(RegionName.tunnel_entrance, [Entrance.tunnel_entrance_to_bus_tunnel]), - RegionData(RegionName.bus_tunnel), - RegionData(RegionName.town, - [Entrance.town_to_community_center, Entrance.town_to_beach, Entrance.town_to_hospital, Entrance.town_to_pierre_general_store, - Entrance.town_to_saloon, Entrance.town_to_alex_house, Entrance.town_to_trailer, Entrance.town_to_mayor_manor, Entrance.town_to_sam_house, - Entrance.town_to_haley_house, Entrance.town_to_sewer, Entrance.town_to_clint_blacksmith, Entrance.town_to_museum, Entrance.town_to_jojamart, - Entrance.purchase_movie_ticket, LogicEntrance.buy_experience_books, LogicEntrance.attend_egg_festival, LogicEntrance.attend_fair, - LogicEntrance.attend_spirit_eve, LogicEntrance.attend_winter_star]), - RegionData(RegionName.beach, - [Entrance.beach_to_willy_fish_shop, Entrance.enter_elliott_house, Entrance.enter_tide_pools, LogicEntrance.attend_luau, - LogicEntrance.attend_moonlight_jellies, LogicEntrance.attend_night_market, LogicEntrance.attend_squidfest]), - RegionData(RegionName.railroad, [Entrance.enter_bathhouse_entrance, Entrance.enter_witch_warp_cave]), - RegionData(RegionName.ranch), - RegionData(RegionName.leah_house), - RegionData(RegionName.mastery_cave), - RegionData(RegionName.sewer, [Entrance.enter_mutant_bug_lair]), - RegionData(RegionName.mutant_bug_lair), - RegionData(RegionName.wizard_tower, [Entrance.enter_wizard_basement, Entrance.use_desert_obelisk, Entrance.use_island_obelisk]), - RegionData(RegionName.wizard_basement), - RegionData(RegionName.tent), - RegionData(RegionName.carpenter, [Entrance.enter_sebastian_room]), - RegionData(RegionName.sebastian_room), - RegionData(RegionName.adventurer_guild, [Entrance.adventurer_guild_to_bedroom]), - RegionData(RegionName.adventurer_guild_bedroom), - RegionData(RegionName.community_center, - [Entrance.access_crafts_room, Entrance.access_pantry, Entrance.access_fish_tank, - Entrance.access_boiler_room, Entrance.access_bulletin_board, Entrance.access_vault]), - RegionData(RegionName.crafts_room), - RegionData(RegionName.pantry), - RegionData(RegionName.fish_tank), - RegionData(RegionName.boiler_room), - RegionData(RegionName.bulletin_board), - RegionData(RegionName.vault), - RegionData(RegionName.hospital, [Entrance.enter_harvey_room]), - RegionData(RegionName.harvey_room), - RegionData(RegionName.pierre_store, [Entrance.enter_sunroom]), - RegionData(RegionName.sunroom), - RegionData(RegionName.saloon, [Entrance.play_journey_of_the_prairie_king, Entrance.play_junimo_kart]), - RegionData(RegionName.jotpk_world_1, [Entrance.reach_jotpk_world_2]), - RegionData(RegionName.jotpk_world_2, [Entrance.reach_jotpk_world_3]), - RegionData(RegionName.jotpk_world_3), - RegionData(RegionName.junimo_kart_1, [Entrance.reach_junimo_kart_2]), - RegionData(RegionName.junimo_kart_2, [Entrance.reach_junimo_kart_3]), - RegionData(RegionName.junimo_kart_3, [Entrance.reach_junimo_kart_4]), - RegionData(RegionName.junimo_kart_4), - RegionData(RegionName.alex_house), - RegionData(RegionName.trailer), - RegionData(RegionName.mayor_house), - RegionData(RegionName.sam_house), - RegionData(RegionName.haley_house), - RegionData(RegionName.blacksmith, [LogicEntrance.blacksmith_copper]), - RegionData(RegionName.museum), - RegionData(RegionName.jojamart, [Entrance.enter_abandoned_jojamart]), - RegionData(RegionName.abandoned_jojamart, [Entrance.enter_movie_theater]), - RegionData(RegionName.movie_ticket_stand), - RegionData(RegionName.movie_theater), - RegionData(RegionName.fish_shop, [Entrance.fish_shop_to_boat_tunnel]), - RegionData(RegionName.boat_tunnel, [Entrance.boat_to_ginger_island], is_ginger_island=True), - RegionData(RegionName.elliott_house), - RegionData(RegionName.tide_pools), - RegionData(RegionName.bathhouse_entrance, [Entrance.enter_locker_room]), - RegionData(RegionName.locker_room, [Entrance.enter_public_bath]), - RegionData(RegionName.public_bath), - RegionData(RegionName.witch_warp_cave, [Entrance.enter_witch_swamp]), - RegionData(RegionName.witch_swamp, [Entrance.enter_witch_hut]), - RegionData(RegionName.witch_hut, [Entrance.witch_warp_to_wizard_basement]), - RegionData(RegionName.quarry, [Entrance.enter_quarry_mine_entrance]), - RegionData(RegionName.quarry_mine_entrance, [Entrance.enter_quarry_mine]), - RegionData(RegionName.quarry_mine), - RegionData(RegionName.secret_woods), - RegionData(RegionName.desert, [Entrance.enter_skull_cavern_entrance, Entrance.enter_oasis, LogicEntrance.attend_desert_festival]), - RegionData(RegionName.oasis, [Entrance.enter_casino]), - RegionData(RegionName.casino), - RegionData(RegionName.skull_cavern_entrance, [Entrance.enter_skull_cavern]), - RegionData(RegionName.skull_cavern, [Entrance.mine_to_skull_cavern_floor_25]), - RegionData(RegionName.skull_cavern_25, [Entrance.mine_to_skull_cavern_floor_50]), - RegionData(RegionName.skull_cavern_50, [Entrance.mine_to_skull_cavern_floor_75]), - RegionData(RegionName.skull_cavern_75, [Entrance.mine_to_skull_cavern_floor_100]), - RegionData(RegionName.skull_cavern_100, [Entrance.mine_to_skull_cavern_floor_125]), - RegionData(RegionName.skull_cavern_125, [Entrance.mine_to_skull_cavern_floor_150]), - RegionData(RegionName.skull_cavern_150, [Entrance.mine_to_skull_cavern_floor_175]), - RegionData(RegionName.skull_cavern_175, [Entrance.mine_to_skull_cavern_floor_200]), - RegionData(RegionName.skull_cavern_200, [Entrance.enter_dangerous_skull_cavern]), - RegionData(RegionName.dangerous_skull_cavern, is_ginger_island=True), - RegionData(RegionName.island_south, - [Entrance.island_south_to_west, Entrance.island_south_to_north, Entrance.island_south_to_east, Entrance.island_south_to_southeast, - Entrance.use_island_resort, Entrance.parrot_express_docks_to_volcano, Entrance.parrot_express_docks_to_dig_site, - Entrance.parrot_express_docks_to_jungle], - is_ginger_island=True), - RegionData(RegionName.island_resort, is_ginger_island=True), - RegionData(RegionName.island_west, - [Entrance.island_west_to_islandfarmhouse, Entrance.island_west_to_gourmand_cave, Entrance.island_west_to_crystals_cave, - Entrance.island_west_to_shipwreck, Entrance.island_west_to_qi_walnut_room, Entrance.use_farm_obelisk, Entrance.parrot_express_jungle_to_docks, - Entrance.parrot_express_jungle_to_dig_site, Entrance.parrot_express_jungle_to_volcano, LogicEntrance.grow_spring_crops_on_island, - LogicEntrance.grow_summer_crops_on_island, LogicEntrance.grow_fall_crops_on_island, LogicEntrance.grow_winter_crops_on_island, - LogicEntrance.grow_indoor_crops_on_island], - is_ginger_island=True), - RegionData(RegionName.island_east, [Entrance.island_east_to_leo_hut, Entrance.island_east_to_island_shrine], is_ginger_island=True), - RegionData(RegionName.island_shrine, is_ginger_island=True), - RegionData(RegionName.island_south_east, [Entrance.island_southeast_to_pirate_cove], is_ginger_island=True), - RegionData(RegionName.island_north, - [Entrance.talk_to_island_trader, Entrance.island_north_to_field_office, Entrance.island_north_to_dig_site, Entrance.island_north_to_volcano, - Entrance.parrot_express_volcano_to_dig_site, Entrance.parrot_express_volcano_to_jungle, Entrance.parrot_express_volcano_to_docks], - is_ginger_island=True), - RegionData(RegionName.volcano, [Entrance.climb_to_volcano_5, Entrance.volcano_to_secret_beach], is_ginger_island=True), - RegionData(RegionName.volcano_secret_beach, is_ginger_island=True), - RegionData(RegionName.volcano_floor_5, [Entrance.talk_to_volcano_dwarf, Entrance.climb_to_volcano_10], is_ginger_island=True), - RegionData(RegionName.volcano_dwarf_shop, is_ginger_island=True), - RegionData(RegionName.volcano_floor_10, is_ginger_island=True), - RegionData(RegionName.island_trader, is_ginger_island=True), - RegionData(RegionName.island_farmhouse, [LogicEntrance.island_cooking], is_ginger_island=True), - RegionData(RegionName.gourmand_frog_cave, is_ginger_island=True), - RegionData(RegionName.colored_crystals_cave, is_ginger_island=True), - RegionData(RegionName.shipwreck, is_ginger_island=True), - RegionData(RegionName.qi_walnut_room, is_ginger_island=True), - RegionData(RegionName.leo_hut, is_ginger_island=True), - RegionData(RegionName.pirate_cove, is_ginger_island=True), - RegionData(RegionName.field_office, is_ginger_island=True), - RegionData(RegionName.dig_site, - [Entrance.dig_site_to_professor_snail_cave, Entrance.parrot_express_dig_site_to_volcano, - Entrance.parrot_express_dig_site_to_docks, Entrance.parrot_express_dig_site_to_jungle], - is_ginger_island=True), - RegionData(RegionName.professor_snail_cave, is_ginger_island=True), - RegionData(RegionName.coop), - RegionData(RegionName.barn), - RegionData(RegionName.shed), - RegionData(RegionName.slime_hutch), - - RegionData(RegionName.mines, [LogicEntrance.talk_to_mines_dwarf, - Entrance.dig_to_mines_floor_5]), - RegionData(RegionName.mines_floor_5, [Entrance.dig_to_mines_floor_10]), - RegionData(RegionName.mines_floor_10, [Entrance.dig_to_mines_floor_15]), - RegionData(RegionName.mines_floor_15, [Entrance.dig_to_mines_floor_20]), - RegionData(RegionName.mines_floor_20, [Entrance.dig_to_mines_floor_25]), - RegionData(RegionName.mines_floor_25, [Entrance.dig_to_mines_floor_30]), - RegionData(RegionName.mines_floor_30, [Entrance.dig_to_mines_floor_35]), - RegionData(RegionName.mines_floor_35, [Entrance.dig_to_mines_floor_40]), - RegionData(RegionName.mines_floor_40, [Entrance.dig_to_mines_floor_45]), - RegionData(RegionName.mines_floor_45, [Entrance.dig_to_mines_floor_50]), - RegionData(RegionName.mines_floor_50, [Entrance.dig_to_mines_floor_55]), - RegionData(RegionName.mines_floor_55, [Entrance.dig_to_mines_floor_60]), - RegionData(RegionName.mines_floor_60, [Entrance.dig_to_mines_floor_65]), - RegionData(RegionName.mines_floor_65, [Entrance.dig_to_mines_floor_70]), - RegionData(RegionName.mines_floor_70, [Entrance.dig_to_mines_floor_75]), - RegionData(RegionName.mines_floor_75, [Entrance.dig_to_mines_floor_80]), - RegionData(RegionName.mines_floor_80, [Entrance.dig_to_mines_floor_85]), - RegionData(RegionName.mines_floor_85, [Entrance.dig_to_mines_floor_90]), - RegionData(RegionName.mines_floor_90, [Entrance.dig_to_mines_floor_95]), - RegionData(RegionName.mines_floor_95, [Entrance.dig_to_mines_floor_100]), - RegionData(RegionName.mines_floor_100, [Entrance.dig_to_mines_floor_105]), - RegionData(RegionName.mines_floor_105, [Entrance.dig_to_mines_floor_110]), - RegionData(RegionName.mines_floor_110, [Entrance.dig_to_mines_floor_115]), - RegionData(RegionName.mines_floor_115, [Entrance.dig_to_mines_floor_120]), - RegionData(RegionName.mines_floor_120, [Entrance.dig_to_dangerous_mines_20, Entrance.dig_to_dangerous_mines_60, Entrance.dig_to_dangerous_mines_100]), - RegionData(RegionName.dangerous_mines_20, is_ginger_island=True), - RegionData(RegionName.dangerous_mines_60, is_ginger_island=True), - RegionData(RegionName.dangerous_mines_100, is_ginger_island=True), - - RegionData(LogicRegion.mines_dwarf_shop), - RegionData(LogicRegion.blacksmith_copper, [LogicEntrance.blacksmith_iron]), - RegionData(LogicRegion.blacksmith_iron, [LogicEntrance.blacksmith_gold]), - RegionData(LogicRegion.blacksmith_gold, [LogicEntrance.blacksmith_iridium]), - RegionData(LogicRegion.blacksmith_iridium), - RegionData(LogicRegion.kitchen), - RegionData(LogicRegion.queen_of_sauce), - RegionData(LogicRegion.fishing), - - RegionData(LogicRegion.spring_farming), - RegionData(LogicRegion.summer_farming, [LogicEntrance.grow_summer_fall_crops_in_summer]), - RegionData(LogicRegion.fall_farming, [LogicEntrance.grow_summer_fall_crops_in_fall]), - RegionData(LogicRegion.winter_farming), - RegionData(LogicRegion.summer_or_fall_farming), - RegionData(LogicRegion.indoor_farming), - - RegionData(LogicRegion.shipping), - RegionData(LogicRegion.traveling_cart, [LogicEntrance.buy_from_traveling_merchant_sunday, - LogicEntrance.buy_from_traveling_merchant_monday, - LogicEntrance.buy_from_traveling_merchant_tuesday, - LogicEntrance.buy_from_traveling_merchant_wednesday, - LogicEntrance.buy_from_traveling_merchant_thursday, - LogicEntrance.buy_from_traveling_merchant_friday, - LogicEntrance.buy_from_traveling_merchant_saturday]), - RegionData(LogicRegion.traveling_cart_sunday), - RegionData(LogicRegion.traveling_cart_monday), - RegionData(LogicRegion.traveling_cart_tuesday), - RegionData(LogicRegion.traveling_cart_wednesday), - RegionData(LogicRegion.traveling_cart_thursday), - RegionData(LogicRegion.traveling_cart_friday), - RegionData(LogicRegion.traveling_cart_saturday), - RegionData(LogicRegion.raccoon_daddy, [LogicEntrance.buy_from_raccoon]), - RegionData(LogicRegion.raccoon_shop), - - RegionData(LogicRegion.egg_festival), - RegionData(LogicRegion.desert_festival), - RegionData(LogicRegion.flower_dance), - RegionData(LogicRegion.luau), - RegionData(LogicRegion.trout_derby), - RegionData(LogicRegion.moonlight_jellies), - RegionData(LogicRegion.fair), - RegionData(LogicRegion.spirit_eve), - RegionData(LogicRegion.festival_of_ice), - RegionData(LogicRegion.night_market), - RegionData(LogicRegion.winter_star), - RegionData(LogicRegion.squidfest), - RegionData(LogicRegion.bookseller_1, [LogicEntrance.buy_year1_books]), - RegionData(LogicRegion.bookseller_2, [LogicEntrance.buy_year3_books]), - RegionData(LogicRegion.bookseller_3), -] - -# Exists and where they lead -vanilla_connections = [ - ConnectionData(Entrance.to_stardew_valley, RegionName.stardew_valley), - ConnectionData(Entrance.to_farmhouse, RegionName.farm_house), - ConnectionData(Entrance.farmhouse_to_farm, RegionName.farm), - ConnectionData(Entrance.downstairs_to_cellar, RegionName.cellar), - ConnectionData(Entrance.farm_to_backwoods, RegionName.backwoods), - ConnectionData(Entrance.farm_to_bus_stop, RegionName.bus_stop), - ConnectionData(Entrance.farm_to_forest, RegionName.forest), - ConnectionData(Entrance.farm_to_farmcave, RegionName.farm_cave, flag=RandomizationFlag.NON_PROGRESSION), - ConnectionData(Entrance.enter_greenhouse, RegionName.greenhouse), - ConnectionData(Entrance.enter_coop, RegionName.coop), - ConnectionData(Entrance.enter_barn, RegionName.barn), - ConnectionData(Entrance.enter_shed, RegionName.shed), - ConnectionData(Entrance.enter_slime_hutch, RegionName.slime_hutch), - ConnectionData(Entrance.use_desert_obelisk, RegionName.desert), - ConnectionData(Entrance.use_island_obelisk, RegionName.island_south, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.use_farm_obelisk, RegionName.farm), - ConnectionData(Entrance.backwoods_to_mountain, RegionName.mountain), - ConnectionData(Entrance.bus_stop_to_town, RegionName.town), - ConnectionData(Entrance.bus_stop_to_tunnel_entrance, RegionName.tunnel_entrance), - ConnectionData(Entrance.tunnel_entrance_to_bus_tunnel, RegionName.bus_tunnel, flag=RandomizationFlag.NON_PROGRESSION), - ConnectionData(Entrance.take_bus_to_desert, RegionName.desert), - ConnectionData(Entrance.forest_to_town, RegionName.town), - ConnectionData(Entrance.forest_to_wizard_tower, RegionName.wizard_tower, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_wizard_basement, RegionName.wizard_basement, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.forest_to_marnie_ranch, RegionName.ranch, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.forest_to_leah_cottage, RegionName.leah_house, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_secret_woods, RegionName.secret_woods), - ConnectionData(Entrance.forest_to_sewer, RegionName.sewer, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.forest_to_mastery_cave, RegionName.mastery_cave, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.MASTERIES), - ConnectionData(Entrance.town_to_sewer, RegionName.sewer, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_mutant_bug_lair, RegionName.mutant_bug_lair, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.mountain_to_railroad, RegionName.railroad), - ConnectionData(Entrance.mountain_to_tent, RegionName.tent, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.mountain_to_leo_treehouse, RegionName.leo_treehouse, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.mountain_to_carpenter_shop, RegionName.carpenter, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.mountain_to_maru_room, RegionName.maru_room, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_sebastian_room, RegionName.sebastian_room, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.mountain_to_adventurer_guild, RegionName.adventurer_guild, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.adventurer_guild_to_bedroom, RegionName.adventurer_guild_bedroom), - ConnectionData(Entrance.enter_quarry, RegionName.quarry), - ConnectionData(Entrance.enter_quarry_mine_entrance, RegionName.quarry_mine_entrance, - flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_quarry_mine, RegionName.quarry_mine), - ConnectionData(Entrance.mountain_to_town, RegionName.town), - ConnectionData(Entrance.town_to_community_center, RegionName.community_center, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.access_crafts_room, RegionName.crafts_room), - ConnectionData(Entrance.access_pantry, RegionName.pantry), - ConnectionData(Entrance.access_fish_tank, RegionName.fish_tank), - ConnectionData(Entrance.access_boiler_room, RegionName.boiler_room), - ConnectionData(Entrance.access_bulletin_board, RegionName.bulletin_board), - ConnectionData(Entrance.access_vault, RegionName.vault), - ConnectionData(Entrance.town_to_hospital, RegionName.hospital, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_harvey_room, RegionName.harvey_room, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.town_to_pierre_general_store, RegionName.pierre_store, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_sunroom, RegionName.sunroom, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.town_to_clint_blacksmith, RegionName.blacksmith, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_saloon, RegionName.saloon, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.play_journey_of_the_prairie_king, RegionName.jotpk_world_1), - ConnectionData(Entrance.reach_jotpk_world_2, RegionName.jotpk_world_2), - ConnectionData(Entrance.reach_jotpk_world_3, RegionName.jotpk_world_3), - ConnectionData(Entrance.play_junimo_kart, RegionName.junimo_kart_1), - ConnectionData(Entrance.reach_junimo_kart_2, RegionName.junimo_kart_2), - ConnectionData(Entrance.reach_junimo_kart_3, RegionName.junimo_kart_3), - ConnectionData(Entrance.reach_junimo_kart_4, RegionName.junimo_kart_4), - ConnectionData(Entrance.town_to_sam_house, RegionName.sam_house, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_haley_house, RegionName.haley_house, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_mayor_manor, RegionName.mayor_house, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_alex_house, RegionName.alex_house, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_trailer, RegionName.trailer, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_museum, RegionName.museum, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.town_to_jojamart, RegionName.jojamart, - flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.purchase_movie_ticket, RegionName.movie_ticket_stand), - ConnectionData(Entrance.enter_abandoned_jojamart, RegionName.abandoned_jojamart), - ConnectionData(Entrance.enter_movie_theater, RegionName.movie_theater), - ConnectionData(Entrance.town_to_beach, RegionName.beach), - ConnectionData(Entrance.enter_elliott_house, RegionName.elliott_house, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.beach_to_willy_fish_shop, RegionName.fish_shop, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.fish_shop_to_boat_tunnel, RegionName.boat_tunnel, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.boat_to_ginger_island, RegionName.island_south, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.enter_tide_pools, RegionName.tide_pools), - ConnectionData(Entrance.mountain_to_the_mines, RegionName.mines, - flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.dig_to_mines_floor_5, RegionName.mines_floor_5), - ConnectionData(Entrance.dig_to_mines_floor_10, RegionName.mines_floor_10), - ConnectionData(Entrance.dig_to_mines_floor_15, RegionName.mines_floor_15), - ConnectionData(Entrance.dig_to_mines_floor_20, RegionName.mines_floor_20), - ConnectionData(Entrance.dig_to_mines_floor_25, RegionName.mines_floor_25), - ConnectionData(Entrance.dig_to_mines_floor_30, RegionName.mines_floor_30), - ConnectionData(Entrance.dig_to_mines_floor_35, RegionName.mines_floor_35), - ConnectionData(Entrance.dig_to_mines_floor_40, RegionName.mines_floor_40), - ConnectionData(Entrance.dig_to_mines_floor_45, RegionName.mines_floor_45), - ConnectionData(Entrance.dig_to_mines_floor_50, RegionName.mines_floor_50), - ConnectionData(Entrance.dig_to_mines_floor_55, RegionName.mines_floor_55), - ConnectionData(Entrance.dig_to_mines_floor_60, RegionName.mines_floor_60), - ConnectionData(Entrance.dig_to_mines_floor_65, RegionName.mines_floor_65), - ConnectionData(Entrance.dig_to_mines_floor_70, RegionName.mines_floor_70), - ConnectionData(Entrance.dig_to_mines_floor_75, RegionName.mines_floor_75), - ConnectionData(Entrance.dig_to_mines_floor_80, RegionName.mines_floor_80), - ConnectionData(Entrance.dig_to_mines_floor_85, RegionName.mines_floor_85), - ConnectionData(Entrance.dig_to_mines_floor_90, RegionName.mines_floor_90), - ConnectionData(Entrance.dig_to_mines_floor_95, RegionName.mines_floor_95), - ConnectionData(Entrance.dig_to_mines_floor_100, RegionName.mines_floor_100), - ConnectionData(Entrance.dig_to_mines_floor_105, RegionName.mines_floor_105), - ConnectionData(Entrance.dig_to_mines_floor_110, RegionName.mines_floor_110), - ConnectionData(Entrance.dig_to_mines_floor_115, RegionName.mines_floor_115), - ConnectionData(Entrance.dig_to_mines_floor_120, RegionName.mines_floor_120), - ConnectionData(Entrance.dig_to_dangerous_mines_20, RegionName.dangerous_mines_20, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.dig_to_dangerous_mines_60, RegionName.dangerous_mines_60, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.dig_to_dangerous_mines_100, RegionName.dangerous_mines_100, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.enter_skull_cavern_entrance, RegionName.skull_cavern_entrance, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_oasis, RegionName.oasis, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_casino, RegionName.casino, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_skull_cavern, RegionName.skull_cavern), - ConnectionData(Entrance.mine_to_skull_cavern_floor_25, RegionName.skull_cavern_25), - ConnectionData(Entrance.mine_to_skull_cavern_floor_50, RegionName.skull_cavern_50), - ConnectionData(Entrance.mine_to_skull_cavern_floor_75, RegionName.skull_cavern_75), - ConnectionData(Entrance.mine_to_skull_cavern_floor_100, RegionName.skull_cavern_100), - ConnectionData(Entrance.mine_to_skull_cavern_floor_125, RegionName.skull_cavern_125), - ConnectionData(Entrance.mine_to_skull_cavern_floor_150, RegionName.skull_cavern_150), - ConnectionData(Entrance.mine_to_skull_cavern_floor_175, RegionName.skull_cavern_175), - ConnectionData(Entrance.mine_to_skull_cavern_floor_200, RegionName.skull_cavern_200), - ConnectionData(Entrance.enter_dangerous_skull_cavern, RegionName.dangerous_skull_cavern, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.enter_witch_warp_cave, RegionName.witch_warp_cave, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_witch_swamp, RegionName.witch_swamp, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_witch_hut, RegionName.witch_hut, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.witch_warp_to_wizard_basement, RegionName.wizard_basement, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_bathhouse_entrance, RegionName.bathhouse_entrance, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), - ConnectionData(Entrance.enter_locker_room, RegionName.locker_room, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.enter_public_bath, RegionName.public_bath, flag=RandomizationFlag.BUILDINGS), - ConnectionData(Entrance.island_south_to_west, RegionName.island_west, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_south_to_north, RegionName.island_north, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_south_to_east, RegionName.island_east, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_south_to_southeast, RegionName.island_south_east, - flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.use_island_resort, RegionName.island_resort, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_west_to_islandfarmhouse, RegionName.island_farmhouse, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_west_to_gourmand_cave, RegionName.gourmand_frog_cave, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_west_to_crystals_cave, RegionName.colored_crystals_cave, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_west_to_shipwreck, RegionName.shipwreck, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_west_to_qi_walnut_room, RegionName.qi_walnut_room, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_east_to_leo_hut, RegionName.leo_hut, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_east_to_island_shrine, RegionName.island_shrine, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_southeast_to_pirate_cove, RegionName.pirate_cove, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_north_to_field_office, RegionName.field_office, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_north_to_dig_site, RegionName.dig_site, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.dig_site_to_professor_snail_cave, RegionName.professor_snail_cave, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.island_north_to_volcano, RegionName.volcano, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.volcano_to_secret_beach, RegionName.volcano_secret_beach, - flag=RandomizationFlag.BUILDINGS | RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.talk_to_island_trader, RegionName.island_trader, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.climb_to_volcano_5, RegionName.volcano_floor_5, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.talk_to_volcano_dwarf, RegionName.volcano_dwarf_shop, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.climb_to_volcano_10, RegionName.volcano_floor_10, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_jungle_to_docks, RegionName.island_south, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_dig_site_to_docks, RegionName.island_south, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_volcano_to_docks, RegionName.island_south, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_volcano_to_jungle, RegionName.island_west, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_docks_to_jungle, RegionName.island_west, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_dig_site_to_jungle, RegionName.island_west, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_docks_to_dig_site, RegionName.dig_site, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_volcano_to_dig_site, RegionName.dig_site, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_jungle_to_dig_site, RegionName.dig_site, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_dig_site_to_volcano, RegionName.island_north, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_docks_to_volcano, RegionName.island_north, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(Entrance.parrot_express_jungle_to_volcano, RegionName.island_north, flag=RandomizationFlag.GINGER_ISLAND), - - ConnectionData(LogicEntrance.talk_to_mines_dwarf, LogicRegion.mines_dwarf_shop), - - ConnectionData(LogicEntrance.buy_from_traveling_merchant, LogicRegion.traveling_cart), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_sunday, LogicRegion.traveling_cart_sunday), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_monday, LogicRegion.traveling_cart_monday), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_tuesday, LogicRegion.traveling_cart_tuesday), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_wednesday, LogicRegion.traveling_cart_wednesday), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_thursday, LogicRegion.traveling_cart_thursday), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_friday, LogicRegion.traveling_cart_friday), - ConnectionData(LogicEntrance.buy_from_traveling_merchant_saturday, LogicRegion.traveling_cart_saturday), - ConnectionData(LogicEntrance.complete_raccoon_requests, LogicRegion.raccoon_daddy), - ConnectionData(LogicEntrance.fish_in_waterfall, LogicRegion.forest_waterfall), - ConnectionData(LogicEntrance.buy_from_raccoon, LogicRegion.raccoon_shop), - ConnectionData(LogicEntrance.farmhouse_cooking, LogicRegion.kitchen), - ConnectionData(LogicEntrance.watch_queen_of_sauce, LogicRegion.queen_of_sauce), - - ConnectionData(LogicEntrance.grow_spring_crops, LogicRegion.spring_farming), - ConnectionData(LogicEntrance.grow_summer_crops, LogicRegion.summer_farming), - ConnectionData(LogicEntrance.grow_fall_crops, LogicRegion.fall_farming), - ConnectionData(LogicEntrance.grow_winter_crops, LogicRegion.winter_farming), - ConnectionData(LogicEntrance.grow_spring_crops_in_greenhouse, LogicRegion.spring_farming), - ConnectionData(LogicEntrance.grow_summer_crops_in_greenhouse, LogicRegion.summer_farming), - ConnectionData(LogicEntrance.grow_fall_crops_in_greenhouse, LogicRegion.fall_farming), - ConnectionData(LogicEntrance.grow_winter_crops_in_greenhouse, LogicRegion.winter_farming), - ConnectionData(LogicEntrance.grow_indoor_crops_in_greenhouse, LogicRegion.indoor_farming), - ConnectionData(LogicEntrance.grow_spring_crops_on_island, LogicRegion.spring_farming, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(LogicEntrance.grow_summer_crops_on_island, LogicRegion.summer_farming, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(LogicEntrance.grow_fall_crops_on_island, LogicRegion.fall_farming, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(LogicEntrance.grow_winter_crops_on_island, LogicRegion.winter_farming, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(LogicEntrance.grow_indoor_crops_on_island, LogicRegion.indoor_farming, flag=RandomizationFlag.GINGER_ISLAND), - ConnectionData(LogicEntrance.grow_summer_fall_crops_in_summer, LogicRegion.summer_or_fall_farming), - ConnectionData(LogicEntrance.grow_summer_fall_crops_in_fall, LogicRegion.summer_or_fall_farming), - - ConnectionData(LogicEntrance.shipping, LogicRegion.shipping), - ConnectionData(LogicEntrance.blacksmith_copper, LogicRegion.blacksmith_copper), - ConnectionData(LogicEntrance.blacksmith_iron, LogicRegion.blacksmith_iron), - ConnectionData(LogicEntrance.blacksmith_gold, LogicRegion.blacksmith_gold), - ConnectionData(LogicEntrance.blacksmith_iridium, LogicRegion.blacksmith_iridium), - ConnectionData(LogicEntrance.fishing, LogicRegion.fishing), - ConnectionData(LogicEntrance.island_cooking, LogicRegion.kitchen), - ConnectionData(LogicEntrance.attend_egg_festival, LogicRegion.egg_festival), - ConnectionData(LogicEntrance.attend_desert_festival, LogicRegion.desert_festival), - ConnectionData(LogicEntrance.attend_flower_dance, LogicRegion.flower_dance), - ConnectionData(LogicEntrance.attend_luau, LogicRegion.luau), - ConnectionData(LogicEntrance.attend_trout_derby, LogicRegion.trout_derby), - ConnectionData(LogicEntrance.attend_moonlight_jellies, LogicRegion.moonlight_jellies), - ConnectionData(LogicEntrance.attend_fair, LogicRegion.fair), - ConnectionData(LogicEntrance.attend_spirit_eve, LogicRegion.spirit_eve), - ConnectionData(LogicEntrance.attend_festival_of_ice, LogicRegion.festival_of_ice), - ConnectionData(LogicEntrance.attend_night_market, LogicRegion.night_market), - ConnectionData(LogicEntrance.attend_winter_star, LogicRegion.winter_star), - ConnectionData(LogicEntrance.attend_squidfest, LogicRegion.squidfest), - ConnectionData(LogicEntrance.buy_experience_books, LogicRegion.bookseller_1), - ConnectionData(LogicEntrance.buy_year1_books, LogicRegion.bookseller_2), - ConnectionData(LogicEntrance.buy_year3_books, LogicRegion.bookseller_3), -] - - -def create_final_regions(world_options) -> List[RegionData]: - final_regions = [] - final_regions.extend(vanilla_regions) - if world_options.mods is None: - return final_regions - for mod in sorted(world_options.mods.value): - if mod not in ModDataList: - continue - for mod_region in ModDataList[mod].regions: - existing_region = next( - (region for region in final_regions if region.name == mod_region.name), None) - if existing_region: - final_regions.remove(existing_region) - if ModificationFlag.MODIFIED in mod_region.flag: - mod_region = modify_vanilla_regions(existing_region, mod_region) - final_regions.append(existing_region.get_merged_with(mod_region.exits)) - continue - final_regions.append(mod_region.get_clone()) - - return final_regions - - -def create_final_connections_and_regions(world_options) -> Tuple[Dict[str, ConnectionData], Dict[str, RegionData]]: - regions_data: Dict[str, RegionData] = {region.name: region for region in create_final_regions(world_options)} - connections = {connection.name: connection for connection in vanilla_connections} - connections = modify_connections_for_mods(connections, sorted(world_options.mods.value)) - include_island = world_options.exclude_ginger_island == ExcludeGingerIsland.option_false - return remove_ginger_island_regions_and_connections(regions_data, connections, include_island) - - -def remove_ginger_island_regions_and_connections(regions_by_name: Dict[str, RegionData], connections: Dict[str, ConnectionData], include_island: bool): - if include_island: - return connections, regions_by_name - - removed_connections = set() - - for connection_name in tuple(connections): - connection = connections[connection_name] - if connection.flag & RandomizationFlag.GINGER_ISLAND: - connections.pop(connection_name) - removed_connections.add(connection_name) - - for region_name in tuple(regions_by_name): - region = regions_by_name[region_name] - if region.is_ginger_island: - regions_by_name.pop(region_name) - else: - regions_by_name[region_name] = region.get_without_exits(removed_connections) - - return connections, regions_by_name - - -def modify_connections_for_mods(connections: Dict[str, ConnectionData], mods: Iterable) -> Dict[str, ConnectionData]: - for mod in mods: - if mod not in ModDataList: - continue - if mod in vanilla_connections_to_remove_by_mod: - for connection_data in vanilla_connections_to_remove_by_mod[mod]: - connections.pop(connection_data.name) - connections.update({connection.name: connection for connection in ModDataList[mod].connections}) - return connections - - -def modify_vanilla_regions(existing_region: RegionData, modified_region: RegionData) -> RegionData: - updated_region = existing_region - region_exits = updated_region.exits - modified_exits = modified_region.exits - for exits in modified_exits: - region_exits.remove(exits) - - return updated_region - - -def create_regions(region_factory: RegionFactory, random: Random, world_options: StardewValleyOptions, content: StardewContent) \ - -> Tuple[Dict[str, Region], Dict[str, Entrance], Dict[str, str]]: - entrances_data, regions_data = create_final_connections_and_regions(world_options) - regions_by_name: Dict[str: Region] = {region_name: region_factory(region_name, regions_data[region_name].exits) for region_name in regions_data} - entrances_by_name: Dict[str: Entrance] = { - entrance.name: entrance - for region in regions_by_name.values() - for entrance in region.exits - if entrance.name in entrances_data - } - - connections, randomized_data = randomize_connections(random, world_options, content, regions_data, entrances_data) - - for connection in connections: - if connection.name in entrances_by_name: - entrances_by_name[connection.name].connect(regions_by_name[connection.destination]) - return regions_by_name, entrances_by_name, randomized_data - - -def randomize_connections(random: Random, world_options: StardewValleyOptions, content: StardewContent, regions_by_name: Dict[str, RegionData], - connections_by_name: Dict[str, ConnectionData]) -> Tuple[List[ConnectionData], Dict[str, str]]: - connections_to_randomize: List[ConnectionData] = [] - if world_options.entrance_randomization == EntranceRandomization.option_pelican_town: - connections_to_randomize = [connections_by_name[connection] for connection in connections_by_name if - RandomizationFlag.PELICAN_TOWN in connections_by_name[connection].flag] - elif world_options.entrance_randomization == EntranceRandomization.option_non_progression: - connections_to_randomize = [connections_by_name[connection] for connection in connections_by_name if - RandomizationFlag.NON_PROGRESSION in connections_by_name[connection].flag] - elif world_options.entrance_randomization == EntranceRandomization.option_buildings or world_options.entrance_randomization == EntranceRandomization.option_buildings_without_house: - connections_to_randomize = [connections_by_name[connection] for connection in connections_by_name if - RandomizationFlag.BUILDINGS in connections_by_name[connection].flag] - elif world_options.entrance_randomization == EntranceRandomization.option_chaos: - connections_to_randomize = [connections_by_name[connection] for connection in connections_by_name if - RandomizationFlag.BUILDINGS in connections_by_name[connection].flag] - connections_to_randomize = remove_excluded_entrances(connections_to_randomize, content) - - # On Chaos, we just add the connections to randomize, unshuffled, and the client does it every day - randomized_data_for_mod = {} - for connection in connections_to_randomize: - randomized_data_for_mod[connection.name] = connection.name - randomized_data_for_mod[connection.reverse] = connection.reverse - return list(connections_by_name.values()), randomized_data_for_mod - - connections_to_randomize = remove_excluded_entrances(connections_to_randomize, content) - random.shuffle(connections_to_randomize) - destination_pool = list(connections_to_randomize) - random.shuffle(destination_pool) - - randomized_connections = randomize_chosen_connections(connections_to_randomize, destination_pool) - add_non_randomized_connections(list(connections_by_name.values()), connections_to_randomize, randomized_connections) - - swap_connections_until_valid(regions_by_name, connections_by_name, randomized_connections, connections_to_randomize, random) - randomized_connections_for_generation = create_connections_for_generation(randomized_connections) - randomized_data_for_mod = create_data_for_mod(randomized_connections, connections_to_randomize) - - return randomized_connections_for_generation, randomized_data_for_mod - - -def remove_excluded_entrances(connections_to_randomize: List[ConnectionData], content: StardewContent) -> List[ConnectionData]: - # FIXME remove when regions are handled in content packs - if content_packs.ginger_island_content_pack.name not in content.registered_packs: - connections_to_randomize = [connection for connection in connections_to_randomize if RandomizationFlag.GINGER_ISLAND not in connection.flag] - if not content.features.skill_progression.are_masteries_shuffled: - connections_to_randomize = [connection for connection in connections_to_randomize if RandomizationFlag.MASTERIES not in connection.flag] - - return connections_to_randomize - - -def randomize_chosen_connections(connections_to_randomize: List[ConnectionData], - destination_pool: List[ConnectionData]) -> Dict[ConnectionData, ConnectionData]: - randomized_connections = {} - for connection in connections_to_randomize: - destination = destination_pool.pop() - randomized_connections[connection] = destination - return randomized_connections - - -def create_connections_for_generation(randomized_connections: Dict[ConnectionData, ConnectionData]) -> List[ConnectionData]: - connections = [] - for connection in randomized_connections: - destination = randomized_connections[connection] - connections.append(ConnectionData(connection.name, destination.destination, destination.reverse)) - return connections - - -def create_data_for_mod(randomized_connections: Dict[ConnectionData, ConnectionData], - connections_to_randomize: List[ConnectionData]) -> Dict[str, str]: - randomized_data_for_mod = {} - for connection in randomized_connections: - if connection not in connections_to_randomize: - continue - destination = randomized_connections[connection] - add_to_mod_data(connection, destination, randomized_data_for_mod) - return randomized_data_for_mod - - -def add_to_mod_data(connection: ConnectionData, destination: ConnectionData, randomized_data_for_mod: Dict[str, str]): - randomized_data_for_mod[connection.name] = destination.name - randomized_data_for_mod[destination.reverse] = connection.reverse - - -def add_non_randomized_connections(all_connections: List[ConnectionData], connections_to_randomize: List[ConnectionData], - randomized_connections: Dict[ConnectionData, ConnectionData]): - for connection in all_connections: - if connection in connections_to_randomize: - continue - randomized_connections[connection] = connection - - -def swap_connections_until_valid(regions_by_name, connections_by_name: Dict[str, ConnectionData], randomized_connections: Dict[ConnectionData, ConnectionData], - connections_to_randomize: List[ConnectionData], random: Random): - while True: - reachable_regions, unreachable_regions = find_reachable_regions(regions_by_name, connections_by_name, randomized_connections) - if not unreachable_regions: - return randomized_connections - swap_one_random_connection(regions_by_name, connections_by_name, randomized_connections, reachable_regions, - unreachable_regions, connections_to_randomize, random) - - -def region_should_be_reachable(region_name: str, connections_in_slot: Iterable[ConnectionData]) -> bool: - if region_name == RegionName.menu: - return True - for connection in connections_in_slot: - if region_name == connection.destination: - return True - return False - - -def find_reachable_regions(regions_by_name, connections_by_name, - randomized_connections: Dict[ConnectionData, ConnectionData]): - reachable_regions = {RegionName.menu} - unreachable_regions = {region for region in regions_by_name.keys()} - # unreachable_regions = {region for region in regions_by_name.keys() if region_should_be_reachable(region, connections_by_name.values())} - unreachable_regions.remove(RegionName.menu) - exits_to_explore = list(regions_by_name[RegionName.menu].exits) - while exits_to_explore: - exit_name = exits_to_explore.pop() - # if exit_name not in connections_by_name: - # continue - exit_connection = connections_by_name[exit_name] - replaced_connection = randomized_connections[exit_connection] - target_region_name = replaced_connection.destination - if target_region_name in reachable_regions: - continue - - target_region = regions_by_name[target_region_name] - reachable_regions.add(target_region_name) - unreachable_regions.remove(target_region_name) - exits_to_explore.extend(target_region.exits) - return reachable_regions, unreachable_regions - - -def swap_one_random_connection(regions_by_name, connections_by_name, randomized_connections: Dict[ConnectionData, ConnectionData], - reachable_regions: Set[str], unreachable_regions: Set[str], - connections_to_randomize: List[ConnectionData], random: Random): - randomized_connections_already_shuffled = {connection: randomized_connections[connection] - for connection in randomized_connections - if connection != randomized_connections[connection]} - unreachable_regions_names_leading_somewhere = [region for region in sorted(unreachable_regions) if len(regions_by_name[region].exits) > 0] - unreachable_regions_leading_somewhere = [regions_by_name[region_name] for region_name in unreachable_regions_names_leading_somewhere] - unreachable_regions_exits_names = [exit_name for region in unreachable_regions_leading_somewhere for exit_name in region.exits] - unreachable_connections = [connections_by_name[exit_name] for exit_name in unreachable_regions_exits_names] - unreachable_connections_that_can_be_randomized = [connection for connection in unreachable_connections if connection in connections_to_randomize] - - chosen_unreachable_entrance = random.choice(unreachable_connections_that_can_be_randomized) - - chosen_reachable_entrance = None - while chosen_reachable_entrance is None or chosen_reachable_entrance not in randomized_connections_already_shuffled: - chosen_reachable_region_name = random.choice(sorted(reachable_regions)) - chosen_reachable_region = regions_by_name[chosen_reachable_region_name] - if not any(chosen_reachable_region.exits): - continue - chosen_reachable_entrance_name = random.choice(chosen_reachable_region.exits) - chosen_reachable_entrance = connections_by_name[chosen_reachable_entrance_name] - - swap_two_connections(chosen_reachable_entrance, chosen_unreachable_entrance, randomized_connections) - - -def swap_two_connections(entrance_1, entrance_2, randomized_connections): - reachable_destination = randomized_connections[entrance_1] - unreachable_destination = randomized_connections[entrance_2] - randomized_connections[entrance_1] = unreachable_destination - randomized_connections[entrance_2] = reachable_destination diff --git a/worlds/stardew_valley/regions/__init__.py b/worlds/stardew_valley/regions/__init__.py new file mode 100644 index 0000000000..63e8afc2fb --- /dev/null +++ b/worlds/stardew_valley/regions/__init__.py @@ -0,0 +1,2 @@ +from .entrance_rando import prepare_mod_data +from .regions import create_regions, RegionFactory diff --git a/worlds/stardew_valley/regions/entrance_rando.py b/worlds/stardew_valley/regions/entrance_rando.py new file mode 100644 index 0000000000..7aa91685e8 --- /dev/null +++ b/worlds/stardew_valley/regions/entrance_rando.py @@ -0,0 +1,73 @@ +from BaseClasses import Region +from entrance_rando import ERPlacementState +from .model import ConnectionData, RandomizationFlag, reverse_connection_name, RegionData +from ..content import StardewContent +from ..options import EntranceRandomization + + +def create_player_randomization_flag(entrance_randomization_choice: EntranceRandomization, content: StardewContent): + """Return the flag that a connection is expected to have to be randomized. Only the bit corresponding to the player randomization choice will be enabled. + + Other bits for content exclusion might also be enabled, tho the preferred solution to exclude content should be to not create those regions at alls, when possible. + """ + flag = RandomizationFlag.NOT_RANDOMIZED + + if entrance_randomization_choice.value == EntranceRandomization.option_disabled: + return flag + + if entrance_randomization_choice == EntranceRandomization.option_pelican_town: + flag |= RandomizationFlag.BIT_PELICAN_TOWN + elif entrance_randomization_choice == EntranceRandomization.option_non_progression: + flag |= RandomizationFlag.BIT_NON_PROGRESSION + elif entrance_randomization_choice in ( + EntranceRandomization.option_buildings, + EntranceRandomization.option_buildings_without_house, + EntranceRandomization.option_chaos + ): + flag |= RandomizationFlag.BIT_BUILDINGS + + if not content.features.skill_progression.are_masteries_shuffled: + flag |= RandomizationFlag.EXCLUDE_MASTERIES + + return flag + + +def connect_regions(region_data_by_name: dict[str, RegionData], connection_data_by_name: dict[str, ConnectionData], regions_by_name: dict[str, Region], + player_randomization_flag: RandomizationFlag) -> None: + for region_name, region_data in region_data_by_name.items(): + origin_region = regions_by_name[region_name] + + for exit_name in region_data.exits: + connection_data = connection_data_by_name[exit_name] + destination_region = regions_by_name[connection_data.destination] + + if connection_data.is_eligible_for_randomization(player_randomization_flag): + create_entrance_rando_target(origin_region, destination_region, connection_data) + else: + origin_region.connect(destination_region, connection_data.name) + + +def create_entrance_rando_target(origin: Region, destination: Region, connection_data: ConnectionData) -> None: + """We need our own function to create the GER targets, because the Stardew Mod have very specific expectations for the name of the entrances. + We need to know exactly which entrances to swap in both directions.""" + origin.create_exit(connection_data.name) + destination.create_er_target(connection_data.reverse) + + +def prepare_mod_data(placements: ERPlacementState) -> dict[str, str]: + """Take the placements from GER and prepare the data for the mod. + The mod require a dictionary detailing which connections need to be swapped. It acts as if the connections are decoupled, so both directions are required. + + For instance, GER will provide placements like (Town to Community Center, Hospital to Town), meaning that the door of the Community Center will instead lead + to the Hospital, and that the exit of the Hospital will lead to the Town by the Community Center door. The StardewAP mod need to know both swaps, being the + original destination of the "Town to Community Center" connection is to be replaced by the original destination of "Town to Hospital", and the original + destination of "Hospital to Town" is to be replaced by the original destination of "Community Center to Town". + """ + + swapped_connections = {} + + for entrance, exit_ in placements.pairings: + swapped_connections[entrance] = reverse_connection_name(exit_) + swapped_connections[exit_] = reverse_connection_name(entrance) + + return swapped_connections diff --git a/worlds/stardew_valley/regions/model.py b/worlds/stardew_valley/regions/model.py new file mode 100644 index 0000000000..07c3901558 --- /dev/null +++ b/worlds/stardew_valley/regions/model.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Container +from dataclasses import dataclass, field +from enum import IntFlag + +connector_keyword = " to " + + +def reverse_connection_name(name: str) -> str | None: + try: + origin, destination = name.split(connector_keyword) + except ValueError: + return None + return f"{destination}{connector_keyword}{origin}" + + +class MergeFlag(IntFlag): + ADD_EXITS = 0 + REMOVE_EXITS = 1 + + +class RandomizationFlag(IntFlag): + NOT_RANDOMIZED = 0 + + # Randomization options + # The first 4 bits are used to mark if an entrance is eligible for randomization according to the entrance randomization options. + BIT_PELICAN_TOWN = 1 # 0b0001 + BIT_NON_PROGRESSION = 1 << 1 # 0b0010 + BIT_BUILDINGS = 1 << 2 # 0b0100 + BIT_EVERYTHING = 1 << 3 # 0b1000 + + # Content flag for entrances exclusions + # The next 2 bits are used to mark if an entrance is to be excluded from randomization according to the content options. + # Those bits must be removed from an entrance flags when then entrance must be excluded. + __UNUSED = 1 << 4 # 0b010000 + EXCLUDE_MASTERIES = 1 << 5 # 0b100000 + + # Entrance groups + # The last bit is used to add additional qualifiers on entrances to group them + # Those bits should be added when an entrance need additional qualifiers. + LEAD_TO_OPEN_AREA = 1 << 6 + + # Tags to apply on connections + EVERYTHING = EXCLUDE_MASTERIES | BIT_EVERYTHING + BUILDINGS = EVERYTHING | BIT_BUILDINGS + NON_PROGRESSION = BUILDINGS | BIT_NON_PROGRESSION + PELICAN_TOWN = NON_PROGRESSION | BIT_PELICAN_TOWN + + +@dataclass(frozen=True) +class RegionData: + name: str + exits: tuple[str, ...] = field(default_factory=tuple) + flag: MergeFlag = MergeFlag.ADD_EXITS + + def __post_init__(self): + assert not isinstance(self.exits, str), "Exits must be a tuple of strings, you probably forgot a trailing comma." + + def merge_with(self, other: RegionData) -> RegionData: + assert self.name == other.name, "Regions must have the same name to be merged" + + if other.flag == MergeFlag.REMOVE_EXITS: + return self.get_without_exits(other.exits) + + merged_exits = self.exits + other.exits + assert len(merged_exits) == len(set(merged_exits)), "Two regions getting merged have duplicated exists..." + + return RegionData(self.name, merged_exits) + + def get_without_exits(self, exits_to_remove: Container[str]) -> RegionData: + exits = tuple(exit_ for exit_ in self.exits if exit_ not in exits_to_remove) + return RegionData(self.name, exits) + + +@dataclass(frozen=True) +class ConnectionData: + name: str + destination: str + flag: RandomizationFlag = RandomizationFlag.NOT_RANDOMIZED + + @property + def reverse(self) -> str | None: + return reverse_connection_name(self.name) + + def is_eligible_for_randomization(self, chosen_randomization_flag: RandomizationFlag) -> bool: + return chosen_randomization_flag and chosen_randomization_flag in self.flag + + +@dataclass(frozen=True) +class ModRegionsData: + mod_name: str + regions: list[RegionData] + connections: list[ConnectionData] diff --git a/worlds/stardew_valley/regions/mods.py b/worlds/stardew_valley/regions/mods.py new file mode 100644 index 0000000000..fca54619f5 --- /dev/null +++ b/worlds/stardew_valley/regions/mods.py @@ -0,0 +1,46 @@ +from collections.abc import Iterable + +from .model import ConnectionData, RegionData, ModRegionsData +from ..mods.region_data import region_data_by_content_pack, vanilla_connections_to_remove_by_content_pack + + +def modify_regions_for_mods(current_regions_by_name: dict[str, RegionData], active_content_packs: Iterable[str]) -> None: + for content_pack in active_content_packs: + try: + region_data = region_data_by_content_pack[content_pack] + except KeyError: + continue + + merge_mod_regions(current_regions_by_name, region_data) + + +def merge_mod_regions(current_regions_by_name: dict[str, RegionData], mod_region_data: ModRegionsData) -> None: + for new_region in mod_region_data.regions: + region_name = new_region.name + try: + current_region = current_regions_by_name[region_name] + except KeyError: + current_regions_by_name[region_name] = new_region + continue + + current_regions_by_name[region_name] = current_region.merge_with(new_region) + + +def modify_connections_for_mods(connections: dict[str, ConnectionData], active_mods: Iterable[str]) -> None: + for active_mod in active_mods: + try: + region_data = region_data_by_content_pack[active_mod] + except KeyError: + continue + + try: + vanilla_connections_to_remove = vanilla_connections_to_remove_by_content_pack[active_mod] + for connection_name in vanilla_connections_to_remove: + connections.pop(connection_name) + except KeyError: + pass + + connections.update({ + connection.name: connection + for connection in region_data.connections + }) diff --git a/worlds/stardew_valley/regions/regions.py b/worlds/stardew_valley/regions/regions.py new file mode 100644 index 0000000000..ceaec5b2ac --- /dev/null +++ b/worlds/stardew_valley/regions/regions.py @@ -0,0 +1,61 @@ +from typing import Protocol + +from BaseClasses import Region +from . import vanilla_data, mods +from .entrance_rando import create_player_randomization_flag, connect_regions +from .model import ConnectionData, RegionData +from ..content import StardewContent +from ..content.vanilla.ginger_island import ginger_island_content_pack +from ..options import StardewValleyOptions + + +class RegionFactory(Protocol): + def __call__(self, name: str) -> Region: + raise NotImplementedError + + +def create_regions(region_factory: RegionFactory, world_options: StardewValleyOptions, content: StardewContent) -> dict[str, Region]: + connection_data_by_name, region_data_by_name = create_connections_and_regions(content.registered_packs) + + regions_by_name: dict[str: Region] = { + region_name: region_factory(region_name) + for region_name in region_data_by_name + } + + randomization_flag = create_player_randomization_flag(world_options.entrance_randomization, content) + connect_regions(region_data_by_name, connection_data_by_name, regions_by_name, randomization_flag) + + return regions_by_name + + +def create_connections_and_regions(active_content_packs: set[str]) -> tuple[dict[str, ConnectionData], dict[str, RegionData]]: + regions_by_name = create_all_regions(active_content_packs) + connections_by_name = create_all_connections(active_content_packs) + + return connections_by_name, regions_by_name + + +def create_all_regions(active_content_packs: set[str]) -> dict[str, RegionData]: + current_regions_by_name = create_vanilla_regions(active_content_packs) + mods.modify_regions_for_mods(current_regions_by_name, sorted(active_content_packs)) + return current_regions_by_name + + +def create_vanilla_regions(active_content_packs: set[str]) -> dict[str, RegionData]: + if ginger_island_content_pack.name in active_content_packs: + return {**vanilla_data.regions_with_ginger_island_by_name} + else: + return {**vanilla_data.regions_without_ginger_island_by_name} + + +def create_all_connections(active_content_packs: set[str]) -> dict[str, ConnectionData]: + connections = create_vanilla_connections(active_content_packs) + mods.modify_connections_for_mods(connections, sorted(active_content_packs)) + return connections + + +def create_vanilla_connections(active_content_packs: set[str]) -> dict[str, ConnectionData]: + if ginger_island_content_pack.name in active_content_packs: + return {**vanilla_data.connections_with_ginger_island_by_name} + else: + return {**vanilla_data.connections_without_ginger_island_by_name} diff --git a/worlds/stardew_valley/regions/vanilla_data.py b/worlds/stardew_valley/regions/vanilla_data.py new file mode 100644 index 0000000000..dbb83e1063 --- /dev/null +++ b/worlds/stardew_valley/regions/vanilla_data.py @@ -0,0 +1,522 @@ +from collections.abc import Mapping +from types import MappingProxyType + +from .model import ConnectionData, RandomizationFlag, RegionData +from ..strings.entrance_names import LogicEntrance, Entrance +from ..strings.region_names import LogicRegion, Region as RegionName + +vanilla_regions: tuple[RegionData, ...] = ( + RegionData(RegionName.menu, (Entrance.to_stardew_valley,)), + RegionData(RegionName.stardew_valley, (Entrance.to_farmhouse,)), + RegionData(RegionName.farm_house, + (Entrance.farmhouse_to_farm, Entrance.downstairs_to_cellar, LogicEntrance.farmhouse_cooking, LogicEntrance.watch_queen_of_sauce)), + RegionData(RegionName.cellar), + RegionData(RegionName.farm, + (Entrance.farm_to_backwoods, Entrance.farm_to_bus_stop, Entrance.farm_to_forest, Entrance.farm_to_farmcave, Entrance.enter_greenhouse, + Entrance.enter_coop, Entrance.enter_barn, Entrance.enter_shed, Entrance.enter_slime_hutch, LogicEntrance.grow_spring_crops, + LogicEntrance.grow_summer_crops, LogicEntrance.grow_fall_crops, LogicEntrance.grow_winter_crops, LogicEntrance.shipping, + LogicEntrance.fishing,)), + RegionData(RegionName.backwoods, (Entrance.backwoods_to_mountain,)), + RegionData(RegionName.bus_stop, + (Entrance.bus_stop_to_town, Entrance.take_bus_to_desert, Entrance.bus_stop_to_tunnel_entrance)), + RegionData(RegionName.forest, + (Entrance.forest_to_town, Entrance.enter_secret_woods, Entrance.forest_to_wizard_tower, Entrance.forest_to_marnie_ranch, + Entrance.forest_to_leah_cottage, Entrance.forest_to_sewer, Entrance.forest_to_mastery_cave, LogicEntrance.buy_from_traveling_merchant, + LogicEntrance.complete_raccoon_requests, LogicEntrance.fish_in_waterfall, LogicEntrance.attend_flower_dance, LogicEntrance.attend_trout_derby, + LogicEntrance.attend_festival_of_ice)), + RegionData(LogicRegion.forest_waterfall), + RegionData(RegionName.farm_cave), + RegionData(RegionName.greenhouse, + (LogicEntrance.grow_spring_crops_in_greenhouse, LogicEntrance.grow_summer_crops_in_greenhouse, LogicEntrance.grow_fall_crops_in_greenhouse, + LogicEntrance.grow_winter_crops_in_greenhouse, LogicEntrance.grow_indoor_crops_in_greenhouse)), + RegionData(RegionName.mountain, + (Entrance.mountain_to_railroad, Entrance.mountain_to_tent, Entrance.mountain_to_carpenter_shop, + Entrance.mountain_to_the_mines, Entrance.enter_quarry, Entrance.mountain_to_adventurer_guild, + Entrance.mountain_to_town, Entrance.mountain_to_maru_room)), + RegionData(RegionName.maru_room), + RegionData(RegionName.tunnel_entrance, (Entrance.tunnel_entrance_to_bus_tunnel,)), + RegionData(RegionName.bus_tunnel), + RegionData(RegionName.town, + (Entrance.town_to_community_center, Entrance.town_to_beach, Entrance.town_to_hospital, Entrance.town_to_pierre_general_store, + Entrance.town_to_saloon, Entrance.town_to_alex_house, Entrance.town_to_trailer, Entrance.town_to_mayor_manor, Entrance.town_to_sam_house, + Entrance.town_to_haley_house, Entrance.town_to_sewer, Entrance.town_to_clint_blacksmith, Entrance.town_to_museum, Entrance.town_to_jojamart, + Entrance.purchase_movie_ticket, LogicEntrance.buy_experience_books, LogicEntrance.attend_egg_festival, LogicEntrance.attend_fair, + LogicEntrance.attend_spirit_eve, LogicEntrance.attend_winter_star)), + RegionData(RegionName.beach, + (Entrance.beach_to_willy_fish_shop, Entrance.enter_elliott_house, Entrance.enter_tide_pools, LogicEntrance.attend_luau, + LogicEntrance.attend_moonlight_jellies, LogicEntrance.attend_night_market, LogicEntrance.attend_squidfest)), + RegionData(RegionName.railroad, (Entrance.enter_bathhouse_entrance, Entrance.enter_witch_warp_cave)), + RegionData(RegionName.ranch), + RegionData(RegionName.leah_house), + RegionData(RegionName.mastery_cave), + RegionData(RegionName.sewer, (Entrance.enter_mutant_bug_lair,)), + RegionData(RegionName.mutant_bug_lair), + RegionData(RegionName.wizard_tower, (Entrance.enter_wizard_basement, Entrance.use_desert_obelisk)), + RegionData(RegionName.wizard_basement), + RegionData(RegionName.tent), + RegionData(RegionName.carpenter, (Entrance.enter_sebastian_room,)), + RegionData(RegionName.sebastian_room), + RegionData(RegionName.adventurer_guild, (Entrance.adventurer_guild_to_bedroom,)), + RegionData(RegionName.adventurer_guild_bedroom), + RegionData(RegionName.community_center, + (Entrance.access_crafts_room, Entrance.access_pantry, Entrance.access_fish_tank, + Entrance.access_boiler_room, Entrance.access_bulletin_board, Entrance.access_vault)), + RegionData(RegionName.crafts_room), + RegionData(RegionName.pantry), + RegionData(RegionName.fish_tank), + RegionData(RegionName.boiler_room), + RegionData(RegionName.bulletin_board), + RegionData(RegionName.vault), + RegionData(RegionName.hospital, (Entrance.enter_harvey_room,)), + RegionData(RegionName.harvey_room), + RegionData(RegionName.pierre_store, (Entrance.enter_sunroom,)), + RegionData(RegionName.sunroom), + RegionData(RegionName.saloon, (Entrance.play_journey_of_the_prairie_king, Entrance.play_junimo_kart)), + RegionData(RegionName.jotpk_world_1, (Entrance.reach_jotpk_world_2,)), + RegionData(RegionName.jotpk_world_2, (Entrance.reach_jotpk_world_3,)), + RegionData(RegionName.jotpk_world_3), + RegionData(RegionName.junimo_kart_1, (Entrance.reach_junimo_kart_2,)), + RegionData(RegionName.junimo_kart_2, (Entrance.reach_junimo_kart_3,)), + RegionData(RegionName.junimo_kart_3, (Entrance.reach_junimo_kart_4,)), + RegionData(RegionName.junimo_kart_4), + RegionData(RegionName.alex_house), + RegionData(RegionName.trailer), + RegionData(RegionName.mayor_house), + RegionData(RegionName.sam_house), + RegionData(RegionName.haley_house), + RegionData(RegionName.blacksmith, (LogicEntrance.blacksmith_copper,)), + RegionData(RegionName.museum), + RegionData(RegionName.jojamart, (Entrance.enter_abandoned_jojamart,)), + RegionData(RegionName.abandoned_jojamart, (Entrance.enter_movie_theater,)), + RegionData(RegionName.movie_ticket_stand), + RegionData(RegionName.movie_theater), + RegionData(RegionName.fish_shop), + RegionData(RegionName.elliott_house), + RegionData(RegionName.tide_pools), + RegionData(RegionName.bathhouse_entrance, (Entrance.enter_locker_room,)), + RegionData(RegionName.locker_room, (Entrance.enter_public_bath,)), + RegionData(RegionName.public_bath), + RegionData(RegionName.witch_warp_cave, (Entrance.enter_witch_swamp,)), + RegionData(RegionName.witch_swamp, (Entrance.enter_witch_hut,)), + RegionData(RegionName.witch_hut, (Entrance.witch_warp_to_wizard_basement,)), + RegionData(RegionName.quarry, (Entrance.enter_quarry_mine_entrance,)), + RegionData(RegionName.quarry_mine_entrance, (Entrance.enter_quarry_mine,)), + RegionData(RegionName.quarry_mine), + RegionData(RegionName.secret_woods), + RegionData(RegionName.desert, (Entrance.enter_skull_cavern_entrance, Entrance.enter_oasis, LogicEntrance.attend_desert_festival)), + RegionData(RegionName.oasis, (Entrance.enter_casino,)), + RegionData(RegionName.casino), + RegionData(RegionName.skull_cavern_entrance, (Entrance.enter_skull_cavern,)), + RegionData(RegionName.skull_cavern, (Entrance.mine_to_skull_cavern_floor_25,)), + RegionData(RegionName.skull_cavern_25, (Entrance.mine_to_skull_cavern_floor_50,)), + RegionData(RegionName.skull_cavern_50, (Entrance.mine_to_skull_cavern_floor_75,)), + RegionData(RegionName.skull_cavern_75, (Entrance.mine_to_skull_cavern_floor_100,)), + RegionData(RegionName.skull_cavern_100, (Entrance.mine_to_skull_cavern_floor_125,)), + RegionData(RegionName.skull_cavern_125, (Entrance.mine_to_skull_cavern_floor_150,)), + RegionData(RegionName.skull_cavern_150, (Entrance.mine_to_skull_cavern_floor_175,)), + RegionData(RegionName.skull_cavern_175, (Entrance.mine_to_skull_cavern_floor_200,)), + RegionData(RegionName.skull_cavern_200), + + RegionData(RegionName.coop), + RegionData(RegionName.barn), + RegionData(RegionName.shed), + RegionData(RegionName.slime_hutch), + + RegionData(RegionName.mines, (LogicEntrance.talk_to_mines_dwarf, Entrance.dig_to_mines_floor_5)), + RegionData(RegionName.mines_floor_5, (Entrance.dig_to_mines_floor_10,)), + RegionData(RegionName.mines_floor_10, (Entrance.dig_to_mines_floor_15,)), + RegionData(RegionName.mines_floor_15, (Entrance.dig_to_mines_floor_20,)), + RegionData(RegionName.mines_floor_20, (Entrance.dig_to_mines_floor_25,)), + RegionData(RegionName.mines_floor_25, (Entrance.dig_to_mines_floor_30,)), + RegionData(RegionName.mines_floor_30, (Entrance.dig_to_mines_floor_35,)), + RegionData(RegionName.mines_floor_35, (Entrance.dig_to_mines_floor_40,)), + RegionData(RegionName.mines_floor_40, (Entrance.dig_to_mines_floor_45,)), + RegionData(RegionName.mines_floor_45, (Entrance.dig_to_mines_floor_50,)), + RegionData(RegionName.mines_floor_50, (Entrance.dig_to_mines_floor_55,)), + RegionData(RegionName.mines_floor_55, (Entrance.dig_to_mines_floor_60,)), + RegionData(RegionName.mines_floor_60, (Entrance.dig_to_mines_floor_65,)), + RegionData(RegionName.mines_floor_65, (Entrance.dig_to_mines_floor_70,)), + RegionData(RegionName.mines_floor_70, (Entrance.dig_to_mines_floor_75,)), + RegionData(RegionName.mines_floor_75, (Entrance.dig_to_mines_floor_80,)), + RegionData(RegionName.mines_floor_80, (Entrance.dig_to_mines_floor_85,)), + RegionData(RegionName.mines_floor_85, (Entrance.dig_to_mines_floor_90,)), + RegionData(RegionName.mines_floor_90, (Entrance.dig_to_mines_floor_95,)), + RegionData(RegionName.mines_floor_95, (Entrance.dig_to_mines_floor_100,)), + RegionData(RegionName.mines_floor_100, (Entrance.dig_to_mines_floor_105,)), + RegionData(RegionName.mines_floor_105, (Entrance.dig_to_mines_floor_110,)), + RegionData(RegionName.mines_floor_110, (Entrance.dig_to_mines_floor_115,)), + RegionData(RegionName.mines_floor_115, (Entrance.dig_to_mines_floor_120,)), + RegionData(RegionName.mines_floor_120), + + RegionData(LogicRegion.mines_dwarf_shop), + RegionData(LogicRegion.blacksmith_copper, (LogicEntrance.blacksmith_iron,)), + RegionData(LogicRegion.blacksmith_iron, (LogicEntrance.blacksmith_gold,)), + RegionData(LogicRegion.blacksmith_gold, (LogicEntrance.blacksmith_iridium,)), + RegionData(LogicRegion.blacksmith_iridium), + RegionData(LogicRegion.kitchen), + RegionData(LogicRegion.queen_of_sauce), + RegionData(LogicRegion.fishing), + + RegionData(LogicRegion.spring_farming), + RegionData(LogicRegion.summer_farming, (LogicEntrance.grow_summer_fall_crops_in_summer,)), + RegionData(LogicRegion.fall_farming, (LogicEntrance.grow_summer_fall_crops_in_fall,)), + RegionData(LogicRegion.winter_farming), + RegionData(LogicRegion.summer_or_fall_farming), + RegionData(LogicRegion.indoor_farming), + + RegionData(LogicRegion.shipping), + RegionData(LogicRegion.traveling_cart, (LogicEntrance.buy_from_traveling_merchant_sunday, + LogicEntrance.buy_from_traveling_merchant_monday, + LogicEntrance.buy_from_traveling_merchant_tuesday, + LogicEntrance.buy_from_traveling_merchant_wednesday, + LogicEntrance.buy_from_traveling_merchant_thursday, + LogicEntrance.buy_from_traveling_merchant_friday, + LogicEntrance.buy_from_traveling_merchant_saturday)), + RegionData(LogicRegion.traveling_cart_sunday), + RegionData(LogicRegion.traveling_cart_monday), + RegionData(LogicRegion.traveling_cart_tuesday), + RegionData(LogicRegion.traveling_cart_wednesday), + RegionData(LogicRegion.traveling_cart_thursday), + RegionData(LogicRegion.traveling_cart_friday), + RegionData(LogicRegion.traveling_cart_saturday), + RegionData(LogicRegion.raccoon_daddy, (LogicEntrance.buy_from_raccoon,)), + RegionData(LogicRegion.raccoon_shop), + + RegionData(LogicRegion.egg_festival), + RegionData(LogicRegion.desert_festival), + RegionData(LogicRegion.flower_dance), + RegionData(LogicRegion.luau), + RegionData(LogicRegion.trout_derby), + RegionData(LogicRegion.moonlight_jellies), + RegionData(LogicRegion.fair), + RegionData(LogicRegion.spirit_eve), + RegionData(LogicRegion.festival_of_ice), + RegionData(LogicRegion.night_market), + RegionData(LogicRegion.winter_star), + RegionData(LogicRegion.squidfest), + RegionData(LogicRegion.bookseller_1, (LogicEntrance.buy_year1_books,)), + RegionData(LogicRegion.bookseller_2, (LogicEntrance.buy_year3_books,)), + RegionData(LogicRegion.bookseller_3), +) +ginger_island_regions = ( + # This overrides the regions from vanilla... When regions are moved to content packs, overriding existing entrances should no longer be necessary. + RegionData(RegionName.mountain, + (Entrance.mountain_to_railroad, Entrance.mountain_to_tent, Entrance.mountain_to_carpenter_shop, + Entrance.mountain_to_the_mines, Entrance.enter_quarry, Entrance.mountain_to_adventurer_guild, + Entrance.mountain_to_town, Entrance.mountain_to_maru_room, Entrance.mountain_to_leo_treehouse)), + RegionData(RegionName.wizard_tower, (Entrance.enter_wizard_basement, Entrance.use_desert_obelisk, Entrance.use_island_obelisk,)), + RegionData(RegionName.fish_shop, (Entrance.fish_shop_to_boat_tunnel,)), + RegionData(RegionName.mines_floor_120, (Entrance.dig_to_dangerous_mines_20, Entrance.dig_to_dangerous_mines_60, Entrance.dig_to_dangerous_mines_100)), + RegionData(RegionName.skull_cavern_200, (Entrance.enter_dangerous_skull_cavern,)), + + RegionData(RegionName.leo_treehouse), + RegionData(RegionName.boat_tunnel, (Entrance.boat_to_ginger_island,)), + RegionData(RegionName.dangerous_skull_cavern), + RegionData(RegionName.island_south, + (Entrance.island_south_to_west, Entrance.island_south_to_north, Entrance.island_south_to_east, Entrance.island_south_to_southeast, + Entrance.use_island_resort, Entrance.parrot_express_docks_to_volcano, Entrance.parrot_express_docks_to_dig_site, + Entrance.parrot_express_docks_to_jungle), ), + RegionData(RegionName.island_resort), + RegionData(RegionName.island_west, + (Entrance.island_west_to_islandfarmhouse, Entrance.island_west_to_gourmand_cave, Entrance.island_west_to_crystals_cave, + Entrance.island_west_to_shipwreck, Entrance.island_west_to_qi_walnut_room, Entrance.use_farm_obelisk, Entrance.parrot_express_jungle_to_docks, + Entrance.parrot_express_jungle_to_dig_site, Entrance.parrot_express_jungle_to_volcano, LogicEntrance.grow_spring_crops_on_island, + LogicEntrance.grow_summer_crops_on_island, LogicEntrance.grow_fall_crops_on_island, LogicEntrance.grow_winter_crops_on_island, + LogicEntrance.grow_indoor_crops_on_island), ), + RegionData(RegionName.island_east, (Entrance.island_east_to_leo_hut, Entrance.island_east_to_island_shrine)), + RegionData(RegionName.island_shrine), + RegionData(RegionName.island_south_east, (Entrance.island_southeast_to_pirate_cove,)), + RegionData(RegionName.island_north, + (Entrance.talk_to_island_trader, Entrance.island_north_to_field_office, Entrance.island_north_to_dig_site, Entrance.island_north_to_volcano, + Entrance.parrot_express_volcano_to_dig_site, Entrance.parrot_express_volcano_to_jungle, Entrance.parrot_express_volcano_to_docks), ), + RegionData(RegionName.volcano, (Entrance.climb_to_volcano_5, Entrance.volcano_to_secret_beach)), + RegionData(RegionName.volcano_secret_beach), + RegionData(RegionName.volcano_floor_5, (Entrance.talk_to_volcano_dwarf, Entrance.climb_to_volcano_10)), + RegionData(RegionName.volcano_dwarf_shop), + RegionData(RegionName.volcano_floor_10), + RegionData(RegionName.island_trader), + RegionData(RegionName.island_farmhouse, (LogicEntrance.island_cooking,)), + RegionData(RegionName.gourmand_frog_cave), + RegionData(RegionName.colored_crystals_cave), + RegionData(RegionName.shipwreck), + RegionData(RegionName.qi_walnut_room), + RegionData(RegionName.leo_hut), + RegionData(RegionName.pirate_cove), + RegionData(RegionName.field_office), + RegionData(RegionName.dig_site, + (Entrance.dig_site_to_professor_snail_cave, Entrance.parrot_express_dig_site_to_volcano, + Entrance.parrot_express_dig_site_to_docks, Entrance.parrot_express_dig_site_to_jungle), ), + + RegionData(RegionName.professor_snail_cave), + RegionData(RegionName.dangerous_mines_20), + RegionData(RegionName.dangerous_mines_60), + RegionData(RegionName.dangerous_mines_100), +) + +# Exists and where they lead +vanilla_connections: tuple[ConnectionData, ...] = ( + ConnectionData(Entrance.to_stardew_valley, RegionName.stardew_valley), + ConnectionData(Entrance.to_farmhouse, RegionName.farm_house), + ConnectionData(Entrance.farmhouse_to_farm, RegionName.farm), + ConnectionData(Entrance.downstairs_to_cellar, RegionName.cellar), + ConnectionData(Entrance.farm_to_backwoods, RegionName.backwoods), + ConnectionData(Entrance.farm_to_bus_stop, RegionName.bus_stop), + ConnectionData(Entrance.farm_to_forest, RegionName.forest), + ConnectionData(Entrance.farm_to_farmcave, RegionName.farm_cave, flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData(Entrance.enter_greenhouse, RegionName.greenhouse), + ConnectionData(Entrance.enter_coop, RegionName.coop), + ConnectionData(Entrance.enter_barn, RegionName.barn), + ConnectionData(Entrance.enter_shed, RegionName.shed), + ConnectionData(Entrance.enter_slime_hutch, RegionName.slime_hutch), + ConnectionData(Entrance.use_desert_obelisk, RegionName.desert), + ConnectionData(Entrance.backwoods_to_mountain, RegionName.mountain), + ConnectionData(Entrance.bus_stop_to_town, RegionName.town), + ConnectionData(Entrance.bus_stop_to_tunnel_entrance, RegionName.tunnel_entrance), + ConnectionData(Entrance.tunnel_entrance_to_bus_tunnel, RegionName.bus_tunnel, flag=RandomizationFlag.NON_PROGRESSION), + ConnectionData(Entrance.take_bus_to_desert, RegionName.desert), + ConnectionData(Entrance.forest_to_town, RegionName.town), + ConnectionData(Entrance.forest_to_wizard_tower, RegionName.wizard_tower, + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_wizard_basement, RegionName.wizard_basement, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.forest_to_marnie_ranch, RegionName.ranch, + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.forest_to_leah_cottage, RegionName.leah_house, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_secret_woods, RegionName.secret_woods), + ConnectionData(Entrance.forest_to_sewer, RegionName.sewer, flag=RandomizationFlag.BUILDINGS), + # We remove the bit for masteries, because the mastery cave is to be excluded from the randomization if masteries are not shuffled. + ConnectionData(Entrance.forest_to_mastery_cave, RegionName.mastery_cave, flag=RandomizationFlag.BUILDINGS ^ RandomizationFlag.EXCLUDE_MASTERIES), + ConnectionData(Entrance.town_to_sewer, RegionName.sewer, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_mutant_bug_lair, RegionName.mutant_bug_lair, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.mountain_to_railroad, RegionName.railroad), + ConnectionData(Entrance.mountain_to_tent, RegionName.tent, + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.mountain_to_carpenter_shop, RegionName.carpenter, + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.mountain_to_maru_room, RegionName.maru_room, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_sebastian_room, RegionName.sebastian_room, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.mountain_to_adventurer_guild, RegionName.adventurer_guild, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.adventurer_guild_to_bedroom, RegionName.adventurer_guild_bedroom), + ConnectionData(Entrance.enter_quarry, RegionName.quarry), + ConnectionData(Entrance.enter_quarry_mine_entrance, RegionName.quarry_mine_entrance, + flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_quarry_mine, RegionName.quarry_mine), + ConnectionData(Entrance.mountain_to_town, RegionName.town), + ConnectionData(Entrance.town_to_community_center, RegionName.community_center, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.access_crafts_room, RegionName.crafts_room), + ConnectionData(Entrance.access_pantry, RegionName.pantry), + ConnectionData(Entrance.access_fish_tank, RegionName.fish_tank), + ConnectionData(Entrance.access_boiler_room, RegionName.boiler_room), + ConnectionData(Entrance.access_bulletin_board, RegionName.bulletin_board), + ConnectionData(Entrance.access_vault, RegionName.vault), + ConnectionData(Entrance.town_to_hospital, RegionName.hospital, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_harvey_room, RegionName.harvey_room, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.town_to_pierre_general_store, RegionName.pierre_store, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_sunroom, RegionName.sunroom, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.town_to_clint_blacksmith, RegionName.blacksmith, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_saloon, RegionName.saloon, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.play_journey_of_the_prairie_king, RegionName.jotpk_world_1), + ConnectionData(Entrance.reach_jotpk_world_2, RegionName.jotpk_world_2), + ConnectionData(Entrance.reach_jotpk_world_3, RegionName.jotpk_world_3), + ConnectionData(Entrance.play_junimo_kart, RegionName.junimo_kart_1), + ConnectionData(Entrance.reach_junimo_kart_2, RegionName.junimo_kart_2), + ConnectionData(Entrance.reach_junimo_kart_3, RegionName.junimo_kart_3), + ConnectionData(Entrance.reach_junimo_kart_4, RegionName.junimo_kart_4), + ConnectionData(Entrance.town_to_sam_house, RegionName.sam_house, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_haley_house, RegionName.haley_house, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_mayor_manor, RegionName.mayor_house, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_alex_house, RegionName.alex_house, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_trailer, RegionName.trailer, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_museum, RegionName.museum, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.town_to_jojamart, RegionName.jojamart, + flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.purchase_movie_ticket, RegionName.movie_ticket_stand), + ConnectionData(Entrance.enter_abandoned_jojamart, RegionName.abandoned_jojamart), + ConnectionData(Entrance.enter_movie_theater, RegionName.movie_theater), + ConnectionData(Entrance.town_to_beach, RegionName.beach), + ConnectionData(Entrance.enter_elliott_house, RegionName.elliott_house, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.beach_to_willy_fish_shop, RegionName.fish_shop, + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_tide_pools, RegionName.tide_pools), + ConnectionData(Entrance.mountain_to_the_mines, RegionName.mines, + flag=RandomizationFlag.NON_PROGRESSION | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.dig_to_mines_floor_5, RegionName.mines_floor_5), + ConnectionData(Entrance.dig_to_mines_floor_10, RegionName.mines_floor_10), + ConnectionData(Entrance.dig_to_mines_floor_15, RegionName.mines_floor_15), + ConnectionData(Entrance.dig_to_mines_floor_20, RegionName.mines_floor_20), + ConnectionData(Entrance.dig_to_mines_floor_25, RegionName.mines_floor_25), + ConnectionData(Entrance.dig_to_mines_floor_30, RegionName.mines_floor_30), + ConnectionData(Entrance.dig_to_mines_floor_35, RegionName.mines_floor_35), + ConnectionData(Entrance.dig_to_mines_floor_40, RegionName.mines_floor_40), + ConnectionData(Entrance.dig_to_mines_floor_45, RegionName.mines_floor_45), + ConnectionData(Entrance.dig_to_mines_floor_50, RegionName.mines_floor_50), + ConnectionData(Entrance.dig_to_mines_floor_55, RegionName.mines_floor_55), + ConnectionData(Entrance.dig_to_mines_floor_60, RegionName.mines_floor_60), + ConnectionData(Entrance.dig_to_mines_floor_65, RegionName.mines_floor_65), + ConnectionData(Entrance.dig_to_mines_floor_70, RegionName.mines_floor_70), + ConnectionData(Entrance.dig_to_mines_floor_75, RegionName.mines_floor_75), + ConnectionData(Entrance.dig_to_mines_floor_80, RegionName.mines_floor_80), + ConnectionData(Entrance.dig_to_mines_floor_85, RegionName.mines_floor_85), + ConnectionData(Entrance.dig_to_mines_floor_90, RegionName.mines_floor_90), + ConnectionData(Entrance.dig_to_mines_floor_95, RegionName.mines_floor_95), + ConnectionData(Entrance.dig_to_mines_floor_100, RegionName.mines_floor_100), + ConnectionData(Entrance.dig_to_mines_floor_105, RegionName.mines_floor_105), + ConnectionData(Entrance.dig_to_mines_floor_110, RegionName.mines_floor_110), + ConnectionData(Entrance.dig_to_mines_floor_115, RegionName.mines_floor_115), + ConnectionData(Entrance.dig_to_mines_floor_120, RegionName.mines_floor_120), + ConnectionData(Entrance.enter_skull_cavern_entrance, RegionName.skull_cavern_entrance, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_oasis, RegionName.oasis, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_casino, RegionName.casino, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_skull_cavern, RegionName.skull_cavern), + ConnectionData(Entrance.mine_to_skull_cavern_floor_25, RegionName.skull_cavern_25), + ConnectionData(Entrance.mine_to_skull_cavern_floor_50, RegionName.skull_cavern_50), + ConnectionData(Entrance.mine_to_skull_cavern_floor_75, RegionName.skull_cavern_75), + ConnectionData(Entrance.mine_to_skull_cavern_floor_100, RegionName.skull_cavern_100), + ConnectionData(Entrance.mine_to_skull_cavern_floor_125, RegionName.skull_cavern_125), + ConnectionData(Entrance.mine_to_skull_cavern_floor_150, RegionName.skull_cavern_150), + ConnectionData(Entrance.mine_to_skull_cavern_floor_175, RegionName.skull_cavern_175), + ConnectionData(Entrance.mine_to_skull_cavern_floor_200, RegionName.skull_cavern_200), + ConnectionData(Entrance.enter_witch_warp_cave, RegionName.witch_warp_cave, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_witch_swamp, RegionName.witch_swamp, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_witch_hut, RegionName.witch_hut, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.witch_warp_to_wizard_basement, RegionName.wizard_basement, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_bathhouse_entrance, RegionName.bathhouse_entrance, + flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.enter_locker_room, RegionName.locker_room, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.enter_public_bath, RegionName.public_bath, flag=RandomizationFlag.BUILDINGS), + ConnectionData(LogicEntrance.talk_to_mines_dwarf, LogicRegion.mines_dwarf_shop), + + ConnectionData(LogicEntrance.buy_from_traveling_merchant, LogicRegion.traveling_cart), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_sunday, LogicRegion.traveling_cart_sunday), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_monday, LogicRegion.traveling_cart_monday), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_tuesday, LogicRegion.traveling_cart_tuesday), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_wednesday, LogicRegion.traveling_cart_wednesday), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_thursday, LogicRegion.traveling_cart_thursday), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_friday, LogicRegion.traveling_cart_friday), + ConnectionData(LogicEntrance.buy_from_traveling_merchant_saturday, LogicRegion.traveling_cart_saturday), + ConnectionData(LogicEntrance.complete_raccoon_requests, LogicRegion.raccoon_daddy), + ConnectionData(LogicEntrance.fish_in_waterfall, LogicRegion.forest_waterfall), + ConnectionData(LogicEntrance.buy_from_raccoon, LogicRegion.raccoon_shop), + ConnectionData(LogicEntrance.farmhouse_cooking, LogicRegion.kitchen), + ConnectionData(LogicEntrance.watch_queen_of_sauce, LogicRegion.queen_of_sauce), + + ConnectionData(LogicEntrance.grow_spring_crops, LogicRegion.spring_farming), + ConnectionData(LogicEntrance.grow_summer_crops, LogicRegion.summer_farming), + ConnectionData(LogicEntrance.grow_fall_crops, LogicRegion.fall_farming), + ConnectionData(LogicEntrance.grow_winter_crops, LogicRegion.winter_farming), + ConnectionData(LogicEntrance.grow_spring_crops_in_greenhouse, LogicRegion.spring_farming), + ConnectionData(LogicEntrance.grow_summer_crops_in_greenhouse, LogicRegion.summer_farming), + ConnectionData(LogicEntrance.grow_fall_crops_in_greenhouse, LogicRegion.fall_farming), + ConnectionData(LogicEntrance.grow_winter_crops_in_greenhouse, LogicRegion.winter_farming), + ConnectionData(LogicEntrance.grow_indoor_crops_in_greenhouse, LogicRegion.indoor_farming), + ConnectionData(LogicEntrance.grow_summer_fall_crops_in_summer, LogicRegion.summer_or_fall_farming), + ConnectionData(LogicEntrance.grow_summer_fall_crops_in_fall, LogicRegion.summer_or_fall_farming), + + ConnectionData(LogicEntrance.shipping, LogicRegion.shipping), + ConnectionData(LogicEntrance.blacksmith_copper, LogicRegion.blacksmith_copper), + ConnectionData(LogicEntrance.blacksmith_iron, LogicRegion.blacksmith_iron), + ConnectionData(LogicEntrance.blacksmith_gold, LogicRegion.blacksmith_gold), + ConnectionData(LogicEntrance.blacksmith_iridium, LogicRegion.blacksmith_iridium), + ConnectionData(LogicEntrance.fishing, LogicRegion.fishing), + ConnectionData(LogicEntrance.attend_egg_festival, LogicRegion.egg_festival), + ConnectionData(LogicEntrance.attend_desert_festival, LogicRegion.desert_festival), + ConnectionData(LogicEntrance.attend_flower_dance, LogicRegion.flower_dance), + ConnectionData(LogicEntrance.attend_luau, LogicRegion.luau), + ConnectionData(LogicEntrance.attend_trout_derby, LogicRegion.trout_derby), + ConnectionData(LogicEntrance.attend_moonlight_jellies, LogicRegion.moonlight_jellies), + ConnectionData(LogicEntrance.attend_fair, LogicRegion.fair), + ConnectionData(LogicEntrance.attend_spirit_eve, LogicRegion.spirit_eve), + ConnectionData(LogicEntrance.attend_festival_of_ice, LogicRegion.festival_of_ice), + ConnectionData(LogicEntrance.attend_night_market, LogicRegion.night_market), + ConnectionData(LogicEntrance.attend_winter_star, LogicRegion.winter_star), + ConnectionData(LogicEntrance.attend_squidfest, LogicRegion.squidfest), + ConnectionData(LogicEntrance.buy_experience_books, LogicRegion.bookseller_1), + ConnectionData(LogicEntrance.buy_year1_books, LogicRegion.bookseller_2), + ConnectionData(LogicEntrance.buy_year3_books, LogicRegion.bookseller_3), +) + +ginger_island_connections = ( + ConnectionData(Entrance.use_island_obelisk, RegionName.island_south), + ConnectionData(Entrance.use_farm_obelisk, RegionName.farm), + ConnectionData(Entrance.mountain_to_leo_treehouse, RegionName.leo_treehouse, flag=RandomizationFlag.BUILDINGS | RandomizationFlag.LEAD_TO_OPEN_AREA), + ConnectionData(Entrance.fish_shop_to_boat_tunnel, RegionName.boat_tunnel, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.boat_to_ginger_island, RegionName.island_south), + ConnectionData(Entrance.enter_dangerous_skull_cavern, RegionName.dangerous_skull_cavern), + ConnectionData(Entrance.dig_to_dangerous_mines_20, RegionName.dangerous_mines_20), + ConnectionData(Entrance.dig_to_dangerous_mines_60, RegionName.dangerous_mines_60), + ConnectionData(Entrance.dig_to_dangerous_mines_100, RegionName.dangerous_mines_100), + ConnectionData(Entrance.island_south_to_west, RegionName.island_west), + ConnectionData(Entrance.island_south_to_north, RegionName.island_north), + ConnectionData(Entrance.island_south_to_east, RegionName.island_east), + ConnectionData(Entrance.island_south_to_southeast, RegionName.island_south_east), + ConnectionData(Entrance.use_island_resort, RegionName.island_resort), + ConnectionData(Entrance.island_west_to_islandfarmhouse, RegionName.island_farmhouse, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_west_to_gourmand_cave, RegionName.gourmand_frog_cave, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_west_to_crystals_cave, RegionName.colored_crystals_cave, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_west_to_shipwreck, RegionName.shipwreck, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_west_to_qi_walnut_room, RegionName.qi_walnut_room, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_east_to_leo_hut, RegionName.leo_hut, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_east_to_island_shrine, RegionName.island_shrine, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_southeast_to_pirate_cove, RegionName.pirate_cove, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_north_to_field_office, RegionName.field_office, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_north_to_dig_site, RegionName.dig_site), + ConnectionData(Entrance.dig_site_to_professor_snail_cave, RegionName.professor_snail_cave, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.island_north_to_volcano, RegionName.volcano, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.volcano_to_secret_beach, RegionName.volcano_secret_beach, flag=RandomizationFlag.BUILDINGS), + ConnectionData(Entrance.talk_to_island_trader, RegionName.island_trader), + ConnectionData(Entrance.climb_to_volcano_5, RegionName.volcano_floor_5), + ConnectionData(Entrance.talk_to_volcano_dwarf, RegionName.volcano_dwarf_shop), + ConnectionData(Entrance.climb_to_volcano_10, RegionName.volcano_floor_10), + ConnectionData(Entrance.parrot_express_jungle_to_docks, RegionName.island_south), + ConnectionData(Entrance.parrot_express_dig_site_to_docks, RegionName.island_south), + ConnectionData(Entrance.parrot_express_volcano_to_docks, RegionName.island_south), + ConnectionData(Entrance.parrot_express_volcano_to_jungle, RegionName.island_west), + ConnectionData(Entrance.parrot_express_docks_to_jungle, RegionName.island_west), + ConnectionData(Entrance.parrot_express_dig_site_to_jungle, RegionName.island_west), + ConnectionData(Entrance.parrot_express_docks_to_dig_site, RegionName.dig_site), + ConnectionData(Entrance.parrot_express_volcano_to_dig_site, RegionName.dig_site), + ConnectionData(Entrance.parrot_express_jungle_to_dig_site, RegionName.dig_site), + ConnectionData(Entrance.parrot_express_dig_site_to_volcano, RegionName.island_north), + ConnectionData(Entrance.parrot_express_docks_to_volcano, RegionName.island_north), + ConnectionData(Entrance.parrot_express_jungle_to_volcano, RegionName.island_north), + ConnectionData(LogicEntrance.grow_spring_crops_on_island, LogicRegion.spring_farming), + ConnectionData(LogicEntrance.grow_summer_crops_on_island, LogicRegion.summer_farming), + ConnectionData(LogicEntrance.grow_fall_crops_on_island, LogicRegion.fall_farming), + ConnectionData(LogicEntrance.grow_winter_crops_on_island, LogicRegion.winter_farming), + ConnectionData(LogicEntrance.grow_indoor_crops_on_island, LogicRegion.indoor_farming), + ConnectionData(LogicEntrance.island_cooking, LogicRegion.kitchen), +) + +connections_without_ginger_island_by_name: Mapping[str, ConnectionData] = MappingProxyType({ + connection.name: connection + for connection in vanilla_connections +}) +regions_without_ginger_island_by_name: Mapping[str, RegionData] = MappingProxyType({ + region.name: region + for region in vanilla_regions +}) + +connections_with_ginger_island_by_name: Mapping[str, ConnectionData] = MappingProxyType({ + connection.name: connection + for connection in vanilla_connections + ginger_island_connections +}) +regions_with_ginger_island_by_name: Mapping[str, RegionData] = MappingProxyType({ + region.name: region + for region in vanilla_regions + ginger_island_regions +}) diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py index e5d7e8863e..2b7eec9960 100644 --- a/worlds/stardew_valley/rules.py +++ b/worlds/stardew_valley/rules.py @@ -195,6 +195,7 @@ def set_entrance_rules(logic: StardewLogic, multiworld, player, world_options: S set_entrance_rule(multiworld, player, Entrance.enter_tide_pools, logic.received("Beach Bridge") | (logic.mod.magic.can_blink())) set_entrance_rule(multiworld, player, Entrance.enter_quarry, logic.received("Bridge Repair") | (logic.mod.magic.can_blink())) set_entrance_rule(multiworld, player, Entrance.enter_secret_woods, logic.tool.has_tool(Tool.axe, "Iron") | (logic.mod.magic.can_blink())) + set_entrance_rule(multiworld, player, Entrance.forest_to_wizard_tower, logic.region.can_reach(Region.community_center)) set_entrance_rule(multiworld, player, Entrance.forest_to_sewer, logic.wallet.has_rusty_key()) set_entrance_rule(multiworld, player, Entrance.town_to_sewer, logic.wallet.has_rusty_key()) set_entrance_rule(multiworld, player, Entrance.enter_abandoned_jojamart, logic.has_abandoned_jojamart()) @@ -442,27 +443,27 @@ def set_walnut_puzzle_rules(logic: StardewLogic, multiworld, player, world_optio if WalnutsanityOptionName.puzzles not in world_options.walnutsanity: return - set_rule(multiworld.get_location("Open Golden Coconut", player), logic.has(Geode.golden_coconut)) - set_rule(multiworld.get_location("Banana Altar", player), logic.has(Fruit.banana)) - set_rule(multiworld.get_location("Leo's Tree", player), logic.tool.has_tool(Tool.axe)) - set_rule(multiworld.get_location("Gem Birds Shrine", player), logic.has(Mineral.amethyst) & logic.has(Mineral.aquamarine) & + set_rule(multiworld.get_location("Walnutsanity: Open Golden Coconut", player), logic.has(Geode.golden_coconut)) + set_rule(multiworld.get_location("Walnutsanity: Banana Altar", player), logic.has(Fruit.banana)) + set_rule(multiworld.get_location("Walnutsanity: Leo's Tree", player), logic.tool.has_tool(Tool.axe)) + set_rule(multiworld.get_location("Walnutsanity: Gem Birds Shrine", player), logic.has(Mineral.amethyst) & logic.has(Mineral.aquamarine) & logic.has(Mineral.emerald) & logic.has(Mineral.ruby) & logic.has(Mineral.topaz) & logic.region.can_reach_all((Region.island_north, Region.island_west, Region.island_east, Region.island_south))) - set_rule(multiworld.get_location("Gourmand Frog Melon", player), logic.has(Fruit.melon) & logic.region.can_reach(Region.island_west)) - set_rule(multiworld.get_location("Gourmand Frog Wheat", player), logic.has(Vegetable.wheat) & - logic.region.can_reach(Region.island_west) & logic.region.can_reach_location("Gourmand Frog Melon")) - set_rule(multiworld.get_location("Gourmand Frog Garlic", player), logic.has(Vegetable.garlic) & - logic.region.can_reach(Region.island_west) & logic.region.can_reach_location("Gourmand Frog Wheat")) - set_rule(multiworld.get_location("Whack A Mole", player), logic.tool.has_tool(Tool.watering_can, ToolMaterial.iridium)) - set_rule(multiworld.get_location("Complete Large Animal Collection", player), logic.walnut.can_complete_large_animal_collection()) - set_rule(multiworld.get_location("Complete Snake Collection", player), logic.walnut.can_complete_snake_collection()) - set_rule(multiworld.get_location("Complete Mummified Frog Collection", player), logic.walnut.can_complete_frog_collection()) - set_rule(multiworld.get_location("Complete Mummified Bat Collection", player), logic.walnut.can_complete_bat_collection()) - set_rule(multiworld.get_location("Purple Flowers Island Survey", player), logic.walnut.can_start_field_office) - set_rule(multiworld.get_location("Purple Starfish Island Survey", player), logic.walnut.can_start_field_office) - set_rule(multiworld.get_location("Protruding Tree Walnut", player), logic.combat.has_slingshot) - set_rule(multiworld.get_location("Starfish Tide Pool", player), logic.tool.has_fishing_rod(1)) - set_rule(multiworld.get_location("Mermaid Song", player), logic.has(Furniture.flute_block)) + set_rule(multiworld.get_location("Walnutsanity: Gourmand Frog Melon", player), logic.has(Fruit.melon) & logic.region.can_reach(Region.island_west)) + set_rule(multiworld.get_location("Walnutsanity: Gourmand Frog Wheat", player), logic.has(Vegetable.wheat) & + logic.region.can_reach(Region.island_west) & logic.region.can_reach_location("Walnutsanity: Gourmand Frog Melon")) + set_rule(multiworld.get_location("Walnutsanity: Gourmand Frog Garlic", player), logic.has(Vegetable.garlic) & + logic.region.can_reach(Region.island_west) & logic.region.can_reach_location("Walnutsanity: Gourmand Frog Wheat")) + set_rule(multiworld.get_location("Walnutsanity: Whack A Mole", player), logic.tool.has_tool(Tool.watering_can, ToolMaterial.iridium)) + set_rule(multiworld.get_location("Walnutsanity: Complete Large Animal Collection", player), logic.walnut.can_complete_large_animal_collection()) + set_rule(multiworld.get_location("Walnutsanity: Complete Snake Collection", player), logic.walnut.can_complete_snake_collection()) + set_rule(multiworld.get_location("Walnutsanity: Complete Mummified Frog Collection", player), logic.walnut.can_complete_frog_collection()) + set_rule(multiworld.get_location("Walnutsanity: Complete Mummified Bat Collection", player), logic.walnut.can_complete_bat_collection()) + set_rule(multiworld.get_location("Walnutsanity: Purple Flowers Island Survey", player), logic.walnut.can_start_field_office) + set_rule(multiworld.get_location("Walnutsanity: Purple Starfish Island Survey", player), logic.walnut.can_start_field_office) + set_rule(multiworld.get_location("Walnutsanity: Protruding Tree Walnut", player), logic.combat.has_slingshot) + set_rule(multiworld.get_location("Walnutsanity: Starfish Tide Pool", player), logic.tool.has_fishing_rod(1)) + set_rule(multiworld.get_location("Walnutsanity: Mermaid Song", player), logic.has(Furniture.flute_block)) def set_walnut_bushes_rules(logic, multiworld, player, world_options): @@ -489,13 +490,13 @@ def set_walnut_repeatable_rules(logic, multiworld, player, world_options): if WalnutsanityOptionName.repeatables not in world_options.walnutsanity: return for i in range(1, 6): - set_rule(multiworld.get_location(f"Fishing Walnut {i}", player), logic.tool.has_fishing_rod(1)) - set_rule(multiworld.get_location(f"Harvesting Walnut {i}", player), logic.skill.can_get_farming_xp) - set_rule(multiworld.get_location(f"Mussel Node Walnut {i}", player), logic.tool.has_tool(Tool.pickaxe)) - set_rule(multiworld.get_location(f"Volcano Rocks Walnut {i}", player), logic.tool.has_tool(Tool.pickaxe)) - set_rule(multiworld.get_location(f"Volcano Monsters Walnut {i}", player), logic.combat.has_galaxy_weapon) - set_rule(multiworld.get_location(f"Volcano Crates Walnut {i}", player), logic.combat.has_any_weapon) - set_rule(multiworld.get_location(f"Tiger Slime Walnut", player), logic.monster.can_kill(Monster.tiger_slime)) + set_rule(multiworld.get_location(f"Walnutsanity: Fishing Walnut {i}", player), logic.tool.has_fishing_rod(1)) + set_rule(multiworld.get_location(f"Walnutsanity: Harvesting Walnut {i}", player), logic.skill.can_get_farming_xp) + set_rule(multiworld.get_location(f"Walnutsanity: Mussel Node Walnut {i}", player), logic.tool.has_tool(Tool.pickaxe)) + set_rule(multiworld.get_location(f"Walnutsanity: Volcano Rocks Walnut {i}", player), logic.tool.has_tool(Tool.pickaxe)) + set_rule(multiworld.get_location(f"Walnutsanity: Volcano Monsters Walnut {i}", player), logic.combat.has_galaxy_weapon) + set_rule(multiworld.get_location(f"Walnutsanity: Volcano Crates Walnut {i}", player), logic.combat.has_any_weapon) + set_rule(multiworld.get_location(f"Walnutsanity: Tiger Slime Walnut", player), logic.monster.can_kill(Monster.tiger_slime)) def set_cropsanity_rules(logic: StardewLogic, multiworld, player, world_content: StardewContent): diff --git a/worlds/stardew_valley/strings/monster_drop_names.py b/worlds/stardew_valley/strings/monster_drop_names.py index df2cacf0c6..8612b3c7b5 100644 --- a/worlds/stardew_valley/strings/monster_drop_names.py +++ b/worlds/stardew_valley/strings/monster_drop_names.py @@ -1,9 +1,4 @@ class Loot: - blue_slime_egg = "Blue Slime Egg" - red_slime_egg = "Red Slime Egg" - purple_slime_egg = "Purple Slime Egg" - green_slime_egg = "Green Slime Egg" - tiger_slime_egg = "Tiger Slime Egg" slime = "Slime" bug_meat = "Bug Meat" bat_wing = "Bat Wing" diff --git a/worlds/stardew_valley/test/TestRegions.py b/worlds/stardew_valley/test/TestRegions.py deleted file mode 100644 index 07e3094fb2..0000000000 --- a/worlds/stardew_valley/test/TestRegions.py +++ /dev/null @@ -1,173 +0,0 @@ -import random -import unittest -from typing import Set - -from BaseClasses import get_seed -from .bases import SVTestCase -from .options.utils import fill_dataclass_with_default -from .. import create_content -from ..options import EntranceRandomization, ExcludeGingerIsland, SkillProgression -from ..regions import vanilla_regions, vanilla_connections, randomize_connections, RandomizationFlag, create_final_connections_and_regions -from ..strings.entrance_names import Entrance as EntranceName -from ..strings.region_names import Region as RegionName - -connections_by_name = {connection.name for connection in vanilla_connections} -regions_by_name = {region.name for region in vanilla_regions} - - -class TestRegions(unittest.TestCase): - def test_region_exits_lead_somewhere(self): - for region in vanilla_regions: - with self.subTest(region=region): - for exit in region.exits: - self.assertIn(exit, connections_by_name, - f"{region.name} is leading to {exit} but it does not exist.") - - def test_connection_lead_somewhere(self): - for connection in vanilla_connections: - with self.subTest(connection=connection): - self.assertIn(connection.destination, regions_by_name, - f"{connection.name} is leading to {connection.destination} but it does not exist.") - - -def explore_connections_tree_up_to_blockers(blocked_entrances: Set[str], connections_by_name, regions_by_name): - explored_entrances = set() - explored_regions = set() - entrances_to_explore = set() - current_node_name = "Menu" - current_node = regions_by_name[current_node_name] - entrances_to_explore.update(current_node.exits) - while entrances_to_explore: - current_entrance_name = entrances_to_explore.pop() - current_entrance = connections_by_name[current_entrance_name] - current_node_name = current_entrance.destination - - explored_entrances.add(current_entrance_name) - explored_regions.add(current_node_name) - - if current_entrance_name in blocked_entrances: - continue - - current_node = regions_by_name[current_node_name] - entrances_to_explore.update({entrance for entrance in current_node.exits if entrance not in explored_entrances}) - return explored_regions - - -class TestEntranceRando(SVTestCase): - - def test_entrance_randomization(self): - for option, flag in [(EntranceRandomization.option_pelican_town, RandomizationFlag.PELICAN_TOWN), - (EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION), - (EntranceRandomization.option_buildings_without_house, RandomizationFlag.BUILDINGS), - (EntranceRandomization.option_buildings, RandomizationFlag.BUILDINGS)]: - sv_options = fill_dataclass_with_default({ - EntranceRandomization.internal_name: option, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, - }) - content = create_content(sv_options) - seed = get_seed() - rand = random.Random(seed) - with self.subTest(flag=flag, msg=f"Seed: {seed}"): - entrances, regions = create_final_connections_and_regions(sv_options) - _, randomized_connections = randomize_connections(rand, sv_options, content, regions, entrances) - - for connection in vanilla_connections: - if flag in connection.flag: - connection_in_randomized = connection.name in randomized_connections - reverse_in_randomized = connection.reverse in randomized_connections - self.assertTrue(connection_in_randomized, f"Connection {connection.name} should be randomized but it is not in the output.") - self.assertTrue(reverse_in_randomized, f"Connection {connection.reverse} should be randomized but it is not in the output.") - - self.assertEqual(len(set(randomized_connections.values())), len(randomized_connections.values()), - f"Connections are duplicated in randomization.") - - def test_entrance_randomization_without_island(self): - for option, flag in [(EntranceRandomization.option_pelican_town, RandomizationFlag.PELICAN_TOWN), - (EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION), - (EntranceRandomization.option_buildings_without_house, RandomizationFlag.BUILDINGS), - (EntranceRandomization.option_buildings, RandomizationFlag.BUILDINGS)]: - - sv_options = fill_dataclass_with_default({ - EntranceRandomization.internal_name: option, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, - }) - content = create_content(sv_options) - seed = get_seed() - rand = random.Random(seed) - with self.subTest(option=option, flag=flag, seed=seed): - entrances, regions = create_final_connections_and_regions(sv_options) - _, randomized_connections = randomize_connections(rand, sv_options, content, regions, entrances) - - for connection in vanilla_connections: - if flag in connection.flag: - if RandomizationFlag.GINGER_ISLAND in connection.flag: - self.assertNotIn(connection.name, randomized_connections, - f"Connection {connection.name} should not be randomized but it is in the output.") - self.assertNotIn(connection.reverse, randomized_connections, - f"Connection {connection.reverse} should not be randomized but it is in the output.") - else: - self.assertIn(connection.name, randomized_connections, - f"Connection {connection.name} should be randomized but it is not in the output.") - self.assertIn(connection.reverse, randomized_connections, - f"Connection {connection.reverse} should be randomized but it is not in the output.") - - self.assertEqual(len(set(randomized_connections.values())), len(randomized_connections.values()), - f"Connections are duplicated in randomization.") - - def test_cannot_put_island_access_on_island(self): - sv_options = fill_dataclass_with_default({ - EntranceRandomization.internal_name: EntranceRandomization.option_buildings, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, - }) - content = create_content(sv_options) - - for i in range(0, 100 if self.skip_long_tests else 10000): - seed = get_seed() - rand = random.Random(seed) - with self.subTest(msg=f"Seed: {seed}"): - entrances, regions = create_final_connections_and_regions(sv_options) - randomized_connections, randomized_data = randomize_connections(rand, sv_options, content, regions, entrances) - connections_by_name = {connection.name: connection for connection in randomized_connections} - - blocked_entrances = {EntranceName.use_island_obelisk, EntranceName.boat_to_ginger_island} - required_regions = {RegionName.wizard_tower, RegionName.boat_tunnel} - self.assert_can_reach_any_region_before_blockers(required_regions, blocked_entrances, connections_by_name, regions) - - def assert_can_reach_any_region_before_blockers(self, required_regions, blocked_entrances, connections_by_name, regions_by_name): - explored_regions = explore_connections_tree_up_to_blockers(blocked_entrances, connections_by_name, regions_by_name) - self.assertTrue(any(region in explored_regions for region in required_regions)) - - -class TestEntranceClassifications(SVTestCase): - - def test_non_progression_are_all_accessible_with_empty_inventory(self): - for option, flag in [(EntranceRandomization.option_pelican_town, RandomizationFlag.PELICAN_TOWN), - (EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION)]: - world_options = { - EntranceRandomization.internal_name: option - } - with self.solo_world_sub_test(world_options=world_options, flag=flag) as (multiworld, sv_world): - ap_entrances = {entrance.name: entrance for entrance in multiworld.get_entrances()} - for randomized_entrance in sv_world.randomized_entrances: - if randomized_entrance in ap_entrances: - ap_entrance_origin = ap_entrances[randomized_entrance] - self.assertTrue(ap_entrance_origin.access_rule(multiworld.state)) - if sv_world.randomized_entrances[randomized_entrance] in ap_entrances: - ap_entrance_destination = multiworld.get_entrance(sv_world.randomized_entrances[randomized_entrance], 1) - self.assertTrue(ap_entrance_destination.access_rule(multiworld.state)) - - def test_no_ginger_island_entrances_when_excluded(self): - world_options = { - EntranceRandomization.internal_name: EntranceRandomization.option_disabled, - ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true - } - with self.solo_world_sub_test(world_options=world_options) as (multiworld, _): - ap_entrances = {entrance.name: entrance for entrance in multiworld.get_entrances()} - entrance_data_by_name = {entrance.name: entrance for entrance in vanilla_connections} - for entrance_name in ap_entrances: - entrance_data = entrance_data_by_name[entrance_name] - with self.subTest(f"{entrance_name}: {entrance_data.flag}"): - self.assertFalse(entrance_data.flag & RandomizationFlag.GINGER_ISLAND) diff --git a/worlds/stardew_valley/test/TestWalnutsanity.py b/worlds/stardew_valley/test/TestWalnutsanity.py index e3411edd02..418eaa87c7 100644 --- a/worlds/stardew_valley/test/TestWalnutsanity.py +++ b/worlds/stardew_valley/test/TestWalnutsanity.py @@ -1,26 +1,46 @@ +import unittest + from .bases import SVTestBase from ..options import ExcludeGingerIsland, Walnutsanity, ToolProgression, SkillProgression from ..strings.ap_names.ap_option_names import WalnutsanityOptionName -class TestWalnutsanityNone(SVTestBase): +class SVWalnutsanityTestBase(SVTestBase): + expected_walnut_locations: set[str] = set() + unexpected_walnut_locations: set[str] = set() + + @classmethod + def setUpClass(cls) -> None: + if cls is SVWalnutsanityTestBase: + raise unittest.SkipTest("Base tests disabled") + + super().setUpClass() + + def test_walnut_locations(self): + location_names = {location.name for location in self.multiworld.get_locations()} + for location in self.expected_walnut_locations: + self.assertIn(location, location_names, f"{location} should be in the location names") + for location in self.unexpected_walnut_locations: + self.assertNotIn(location, location_names, f"{location} should not be in the location names") + + +class TestWalnutsanityNone(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: Walnutsanity.preset_none, SkillProgression: ToolProgression.option_progressive, ToolProgression: ToolProgression.option_progressive, } - - def test_no_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertNotIn("Open Golden Coconut", location_names) - self.assertNotIn("Fishing Walnut 4", location_names) - self.assertNotIn("Journal Scrap #6", location_names) - self.assertNotIn("Starfish Triangle", location_names) - self.assertNotIn("Bush Behind Coconut Tree", location_names) - self.assertNotIn("Purple Starfish Island Survey", location_names) - self.assertNotIn("Volcano Monsters Walnut 3", location_names) - self.assertNotIn("Cliff Over Island South Bush", location_names) + unexpected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Purple Starfish Island Survey", + "Walnutsanity: Volcano Monsters Walnut 3", + "Walnutsanity: Cliff Over Island South Bush", + } def test_logic_received_walnuts(self): # You need to receive 0, and collect 40 @@ -48,28 +68,30 @@ class TestWalnutsanityNone(SVTestBase): self.assertTrue(self.multiworld.state.can_reach_location("Parrot Express", self.player)) -class TestWalnutsanityPuzzles(SVTestBase): +class TestWalnutsanityPuzzles(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: frozenset({WalnutsanityOptionName.puzzles}), SkillProgression: ToolProgression.option_progressive, ToolProgression: ToolProgression.option_progressive, } - - def test_only_puzzle_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertIn("Open Golden Coconut", location_names) - self.assertNotIn("Fishing Walnut 4", location_names) - self.assertNotIn("Journal Scrap #6", location_names) - self.assertNotIn("Starfish Triangle", location_names) - self.assertNotIn("Bush Behind Coconut Tree", location_names) - self.assertIn("Purple Starfish Island Survey", location_names) - self.assertNotIn("Volcano Monsters Walnut 3", location_names) - self.assertNotIn("Cliff Over Island South Bush", location_names) + expected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Purple Starfish Island Survey", + } + unexpected_walnut_locations = { + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Volcano Monsters Walnut 3", + "Walnutsanity: Cliff Over Island South Bush", + } def test_field_office_locations_require_professor_snail(self): - location_names = ["Complete Large Animal Collection", "Complete Snake Collection", "Complete Mummified Frog Collection", - "Complete Mummified Bat Collection", "Purple Flowers Island Survey", "Purple Starfish Island Survey", ] + location_names = ["Walnutsanity: Complete Large Animal Collection", "Walnutsanity: Complete Snake Collection", + "Walnutsanity: Complete Mummified Frog Collection", "Walnutsanity: Complete Mummified Bat Collection", + "Walnutsanity: Purple Flowers Island Survey", "Walnutsanity: Purple Starfish Island Survey", ] self.collect("Island Obelisk") self.collect("Island North Turtle") self.collect("Island West Turtle") @@ -90,40 +112,42 @@ class TestWalnutsanityPuzzles(SVTestBase): self.assert_can_reach_location(location) -class TestWalnutsanityBushes(SVTestBase): +class TestWalnutsanityBushes(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: frozenset({WalnutsanityOptionName.bushes}), } - - def test_only_bush_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertNotIn("Open Golden Coconut", location_names) - self.assertNotIn("Fishing Walnut 4", location_names) - self.assertNotIn("Journal Scrap #6", location_names) - self.assertNotIn("Starfish Triangle", location_names) - self.assertIn("Bush Behind Coconut Tree", location_names) - self.assertNotIn("Purple Starfish Island Survey", location_names) - self.assertNotIn("Volcano Monsters Walnut 3", location_names) - self.assertIn("Cliff Over Island South Bush", location_names) + expected_walnut_locations = { + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Cliff Over Island South Bush", + } + unexpected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + "Walnutsanity: Purple Starfish Island Survey", + "Walnutsanity: Volcano Monsters Walnut 3", + } -class TestWalnutsanityPuzzlesAndBushes(SVTestBase): +class TestWalnutsanityPuzzlesAndBushes(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: frozenset({WalnutsanityOptionName.puzzles, WalnutsanityOptionName.bushes}), } - - def test_only_bush_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertIn("Open Golden Coconut", location_names) - self.assertNotIn("Fishing Walnut 4", location_names) - self.assertNotIn("Journal Scrap #6", location_names) - self.assertNotIn("Starfish Triangle", location_names) - self.assertIn("Bush Behind Coconut Tree", location_names) - self.assertIn("Purple Starfish Island Survey", location_names) - self.assertNotIn("Volcano Monsters Walnut 3", location_names) - self.assertIn("Cliff Over Island South Bush", location_names) + expected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Purple Starfish Island Survey", + "Walnutsanity: Cliff Over Island South Bush", + } + unexpected_walnut_locations = { + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + "Walnutsanity: Volcano Monsters Walnut 3", + } def test_logic_received_walnuts(self): # You need to receive 25, and collect 15 @@ -136,58 +160,59 @@ class TestWalnutsanityPuzzlesAndBushes(SVTestBase): self.assertTrue(self.multiworld.state.can_reach_location("Parrot Express", self.player)) -class TestWalnutsanityDigSpots(SVTestBase): +class TestWalnutsanityDigSpots(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: frozenset({WalnutsanityOptionName.dig_spots}), } - - def test_only_dig_spots_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertNotIn("Open Golden Coconut", location_names) - self.assertNotIn("Fishing Walnut 4", location_names) - self.assertIn("Journal Scrap #6", location_names) - self.assertIn("Starfish Triangle", location_names) - self.assertNotIn("Bush Behind Coconut Tree", location_names) - self.assertNotIn("Purple Starfish Island Survey", location_names) - self.assertNotIn("Volcano Monsters Walnut 3", location_names) - self.assertNotIn("Cliff Over Island South Bush", location_names) + expected_walnut_locations = { + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + } + unexpected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Purple Starfish Island Survey", + "Walnutsanity: Volcano Monsters Walnut 3", + "Walnutsanity: Cliff Over Island South Bush", + } -class TestWalnutsanityRepeatables(SVTestBase): +class TestWalnutsanityRepeatables(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: frozenset({WalnutsanityOptionName.repeatables}), } - - def test_only_repeatable_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertNotIn("Open Golden Coconut", location_names) - self.assertIn("Fishing Walnut 4", location_names) - self.assertNotIn("Journal Scrap #6", location_names) - self.assertNotIn("Starfish Triangle", location_names) - self.assertNotIn("Bush Behind Coconut Tree", location_names) - self.assertNotIn("Purple Starfish Island Survey", location_names) - self.assertIn("Volcano Monsters Walnut 3", location_names) - self.assertNotIn("Cliff Over Island South Bush", location_names) + expected_walnut_locations = { + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Volcano Monsters Walnut 3", + } + unexpected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Purple Starfish Island Survey", + "Walnutsanity: Cliff Over Island South Bush", + } -class TestWalnutsanityAll(SVTestBase): +class TestWalnutsanityAll(SVWalnutsanityTestBase): options = { ExcludeGingerIsland: ExcludeGingerIsland.option_false, Walnutsanity: Walnutsanity.preset_all, } - - def test_all_walnut_locations(self): - location_names = {location.name for location in self.multiworld.get_locations()} - self.assertIn("Open Golden Coconut", location_names) - self.assertIn("Fishing Walnut 4", location_names) - self.assertIn("Journal Scrap #6", location_names) - self.assertIn("Starfish Triangle", location_names) - self.assertIn("Bush Behind Coconut Tree", location_names) - self.assertIn("Purple Starfish Island Survey", location_names) - self.assertIn("Volcano Monsters Walnut 3", location_names) - self.assertIn("Cliff Over Island South Bush", location_names) + expected_walnut_locations = { + "Walnutsanity: Open Golden Coconut", + "Walnutsanity: Fishing Walnut 4", + "Walnutsanity: Journal Scrap #6", + "Walnutsanity: Starfish Triangle", + "Walnutsanity: Bush Behind Coconut Tree", + "Walnutsanity: Purple Starfish Island Survey", + "Walnutsanity: Volcano Monsters Walnut 3", + "Walnutsanity: Cliff Over Island South Bush", + } def test_logic_received_walnuts(self): # You need to receive 40, and collect 4 diff --git a/worlds/stardew_valley/test/assertion/rule_assert.py b/worlds/stardew_valley/test/assertion/rule_assert.py index 02362f2d15..39b69a529f 100644 --- a/worlds/stardew_valley/test/assertion/rule_assert.py +++ b/worlds/stardew_valley/test/assertion/rule_assert.py @@ -1,7 +1,7 @@ from typing import List from unittest import TestCase -from BaseClasses import CollectionState, Location, Region +from BaseClasses import CollectionState, Location, Region, Entrance from ...stardew_rule import StardewRule, false_, MISSING_ITEM, Reach from ...stardew_rule.rule_explain import explain @@ -79,3 +79,13 @@ class RuleAssertMixin(TestCase): except KeyError as e: raise AssertionError(f"Error while checking region {region_name}: {e}" f"\nExplanation: {expl}") + + def assert_can_reach_entrance(self, entrance: Entrance | str, state: CollectionState) -> None: + entrance_name = entrance.name if isinstance(entrance, Entrance) else entrance + expl = explain(Reach(entrance_name, "Entrance", 1), state) + try: + can_reach = state.can_reach_entrance(entrance_name, 1) + self.assertTrue(can_reach, expl) + except KeyError as e: + raise AssertionError(f"Error while checking entrance {entrance_name}: {e}" + f"\nExplanation: {expl}") diff --git a/worlds/stardew_valley/test/bases.py b/worlds/stardew_valley/test/bases.py index affc20cde1..a285218399 100644 --- a/worlds/stardew_valley/test/bases.py +++ b/worlds/stardew_valley/test/bases.py @@ -7,7 +7,7 @@ import unittest from contextlib import contextmanager from typing import Optional, Dict, Union, Any, List, Iterable -from BaseClasses import get_seed, MultiWorld, Location, Item, CollectionState +from BaseClasses import get_seed, MultiWorld, Location, Item, CollectionState, Entrance from test.bases import WorldTestBase from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld from worlds.AutoWorld import call_all @@ -179,6 +179,11 @@ class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase): state = self.multiworld.state super().assert_cannot_reach_location(location, state) + def assert_can_reach_entrance(self, entrance: Entrance | str, state: CollectionState | None = None) -> None: + if state is None: + state = self.multiworld.state + super().assert_can_reach_entrance(entrance, state) + pre_generated_worlds = {} diff --git a/worlds/stardew_valley/test/mods/TestMods.py b/worlds/stardew_valley/test/mods/TestMods.py index be6ce71076..8cff10b4fc 100644 --- a/worlds/stardew_valley/test/mods/TestMods.py +++ b/worlds/stardew_valley/test/mods/TestMods.py @@ -1,17 +1,13 @@ -import random from typing import ClassVar -from BaseClasses import get_seed from test.param import classvar_matrix from ..TestGeneration import get_all_permanent_progression_items from ..assertion import ModAssertMixin, WorldAssertMixin from ..bases import SVTestCase, SVTestBase, solo_multiworld from ..options.presets import allsanity_mods_6_x_x -from ..options.utils import fill_dataclass_with_default -from ... import options, Group, create_content +from ... import options, Group from ...mods.mod_data import ModNames from ...options.options import all_mods -from ...regions import RandomizationFlag, randomize_connections, create_final_connections_and_regions class TestCanGenerateAllsanityWithMods(WorldAssertMixin, ModAssertMixin, SVTestCase): @@ -117,39 +113,6 @@ class TestNoGingerIslandModItemGeneration(SVTestBase): self.assertIn(progression_item.name, all_created_items) -class TestModEntranceRando(SVTestCase): - - def test_mod_entrance_randomization(self): - for option, flag in [(options.EntranceRandomization.option_pelican_town, RandomizationFlag.PELICAN_TOWN), - (options.EntranceRandomization.option_non_progression, RandomizationFlag.NON_PROGRESSION), - (options.EntranceRandomization.option_buildings_without_house, RandomizationFlag.BUILDINGS), - (options.EntranceRandomization.option_buildings, RandomizationFlag.BUILDINGS)]: - sv_options = fill_dataclass_with_default({ - options.EntranceRandomization.internal_name: option, - options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, - options.Mods.internal_name: frozenset(options.Mods.valid_keys) - }) - content = create_content(sv_options) - seed = get_seed() - rand = random.Random(seed) - with self.subTest(option=option, flag=flag, seed=seed): - final_connections, final_regions = create_final_connections_and_regions(sv_options) - - _, randomized_connections = randomize_connections(rand, sv_options, content, final_regions, final_connections) - - for connection_name in final_connections: - connection = final_connections[connection_name] - if flag in connection.flag: - connection_in_randomized = connection_name in randomized_connections - reverse_in_randomized = connection.reverse in randomized_connections - self.assertTrue(connection_in_randomized, f"Connection {connection_name} should be randomized but it is not in the output") - self.assertTrue(reverse_in_randomized, f"Connection {connection.reverse} should be randomized but it is not in the output.") - - self.assertEqual(len(set(randomized_connections.values())), len(randomized_connections.values()), - f"Connections are duplicated in randomization.") - - class TestVanillaLogicAlternativeWhenQuestsAreNotRandomized(WorldAssertMixin, SVTestBase): """We often forget to add an alternative rule that works when quests are not randomized. When this happens, some Location are not reachable because they depend on items that are only added to the pool when quests are randomized. diff --git a/worlds/stardew_valley/test/regions/TestEntranceClassifications.py b/worlds/stardew_valley/test/regions/TestEntranceClassifications.py new file mode 100644 index 0000000000..4bc13cb51c --- /dev/null +++ b/worlds/stardew_valley/test/regions/TestEntranceClassifications.py @@ -0,0 +1,36 @@ +from ..bases import SVTestBase +from ... import options +from ...regions.model import RandomizationFlag +from ...regions.regions import create_all_connections + + +class EntranceRandomizationAssertMixin: + + def assert_non_progression_are_all_accessible_with_empty_inventory(self: SVTestBase): + all_connections = create_all_connections(self.world.content.registered_packs) + non_progression_connections = [connection for connection in all_connections.values() if RandomizationFlag.BIT_NON_PROGRESSION in connection.flag] + + for non_progression_connections in non_progression_connections: + with self.subTest(connection=non_progression_connections.name): + self.assert_can_reach_entrance(non_progression_connections.name) + + +# This test does not actually need to generate with entrance randomization. Entrances rules are the same regardless of the randomization. +class TestVanillaEntranceClassifications(EntranceRandomizationAssertMixin, SVTestBase): + options = { + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false, + options.Mods: frozenset() + } + + def test_non_progression_are_all_accessible_with_empty_inventory(self): + self.assert_non_progression_are_all_accessible_with_empty_inventory() + + +class TestModdedEntranceClassifications(EntranceRandomizationAssertMixin, SVTestBase): + options = { + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false, + options.Mods: frozenset(options.Mods.valid_keys) + } + + def test_non_progression_are_all_accessible_with_empty_inventory(self): + self.assert_non_progression_are_all_accessible_with_empty_inventory() diff --git a/worlds/stardew_valley/test/regions/TestEntranceRandomization.py b/worlds/stardew_valley/test/regions/TestEntranceRandomization.py new file mode 100644 index 0000000000..15c46637ab --- /dev/null +++ b/worlds/stardew_valley/test/regions/TestEntranceRandomization.py @@ -0,0 +1,167 @@ +from collections import deque +from collections.abc import Collection +from unittest.mock import patch, Mock + +from BaseClasses import get_seed, MultiWorld, Entrance +from ..assertion import WorldAssertMixin +from ..bases import SVTestCase, solo_multiworld +from ... import options +from ...mods.mod_data import ModNames +from ...options import EntranceRandomization, ExcludeGingerIsland, SkillProgression +from ...options.options import all_mods +from ...regions.entrance_rando import create_entrance_rando_target, prepare_mod_data, connect_regions +from ...regions.model import RegionData, ConnectionData, RandomizationFlag +from ...strings.entrance_names import Entrance as EntranceName +from ...strings.region_names import Region as RegionName + + +class TestEntranceRando(SVTestCase): + + def test_given_connection_matching_randomization_when_connect_regions_then_make_connection_entrance_rando_target(self): + region_data_by_name = { + "Region1": RegionData("Region1", ("randomized_connection", "not_randomized")), + "Region2": RegionData("Region2"), + "Region3": RegionData("Region3"), + } + connection_data_by_name = { + "randomized_connection": ConnectionData("randomized_connection", "Region2", flag=RandomizationFlag.PELICAN_TOWN), + "not_randomized": ConnectionData("not_randomized", "Region2", flag=RandomizationFlag.BUILDINGS), + } + regions_by_name = { + "Region1": Mock(), + "Region2": Mock(), + "Region3": Mock(), + } + player_randomization_flag = RandomizationFlag.BIT_PELICAN_TOWN + + with patch("worlds.stardew_valley.regions.entrance_rando.create_entrance_rando_target") as mock_create_entrance_rando_target: + connect_regions(region_data_by_name, connection_data_by_name, regions_by_name, player_randomization_flag) + + expected_origin, expected_destination = regions_by_name["Region1"], regions_by_name["Region2"] + expected_connection = connection_data_by_name["randomized_connection"] + mock_create_entrance_rando_target.assert_called_once_with(expected_origin, expected_destination, expected_connection) + + def test_when_create_entrance_rando_target_then_create_exit_and_er_target(self): + origin = Mock() + destination = Mock() + connection_data = ConnectionData("origin to destination", "destination") + + create_entrance_rando_target(origin, destination, connection_data) + + origin.create_exit.assert_called_once_with("origin to destination") + destination.create_er_target.assert_called_once_with("destination to origin") + + def test_when_prepare_mod_data_then_swapped_connections_contains_both_directions(self): + placements = Mock(pairings=[("A to B", "C to A"), ("C to D", "A to C")]) + + swapped_connections = prepare_mod_data(placements) + + self.assertEqual({"A to B": "A to C", "C to A": "B to A", "C to D": "C to A", "A to C": "D to C"}, swapped_connections) + + +class TestEntranceRandoCreatesValidWorlds(WorldAssertMixin, SVTestCase): + + # The following tests validate that ER still generates winnable and logically-sane games with given mods. + # Mods that do not interact with entrances are skipped + # Not all ER settings are tested, because 'buildings' is, essentially, a superset of all others + def test_ginger_island_excluded_buildings(self): + world_options = { + options.EntranceRandomization: options.EntranceRandomization.option_buildings, + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_true + } + with solo_multiworld(world_options) as (multi_world, _): + self.assert_basic_checks(multi_world) + + def test_deepwoods_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.deepwoods, options.EntranceRandomization.option_buildings) + + def test_juna_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.juna, options.EntranceRandomization.option_buildings) + + def test_jasper_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.jasper, options.EntranceRandomization.option_buildings) + + def test_alec_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.alec, options.EntranceRandomization.option_buildings) + + def test_yoba_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.yoba, options.EntranceRandomization.option_buildings) + + def test_eugene_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.eugene, options.EntranceRandomization.option_buildings) + + def test_ayeisha_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.ayeisha, options.EntranceRandomization.option_buildings) + + def test_riley_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.riley, options.EntranceRandomization.option_buildings) + + def test_sve_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.sve, options.EntranceRandomization.option_buildings) + + def test_alecto_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.alecto, options.EntranceRandomization.option_buildings) + + def test_lacey_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.lacey, options.EntranceRandomization.option_buildings) + + def test_boarding_house_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(ModNames.boarding_house, options.EntranceRandomization.option_buildings) + + def test_all_mods_entrance_randomization_buildings(self): + self.perform_basic_checks_on_mod_with_er(all_mods, options.EntranceRandomization.option_buildings) + + def perform_basic_checks_on_mod_with_er(self, mods: str | set[str], er_option: int) -> None: + if isinstance(mods, str): + mods = {mods} + world_options = { + options.EntranceRandomization: er_option, + options.Mods: frozenset(mods), + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false + } + with solo_multiworld(world_options) as (multi_world, _): + self.assert_basic_checks(multi_world) + + +# GER should have this covered, but it's good to have a backup +class TestGingerIslandEntranceRando(SVTestCase): + def test_cannot_put_island_access_on_island(self): + test_options = { + options.EntranceRandomization: EntranceRandomization.option_buildings, + options.ExcludeGingerIsland: ExcludeGingerIsland.option_false, + options.SkillProgression: SkillProgression.option_progressive_with_masteries, + } + + blocked_entrances = {EntranceName.use_island_obelisk, EntranceName.boat_to_ginger_island} + required_regions = {RegionName.wizard_tower, RegionName.boat_tunnel} + + for i in range(0, 10 if self.skip_long_tests else 1000): + seed = get_seed() + with self.solo_world_sub_test(f"Seed: {seed}", world_options=test_options, world_caching=False, seed=seed) as (multiworld, world): + self.assert_can_reach_any_region_before_blockers(required_regions, blocked_entrances, multiworld) + + def assert_can_reach_any_region_before_blockers(self, required_regions: Collection[str], blocked_entrances: Collection[str], multiworld: MultiWorld): + explored_regions = explore_regions_up_to_blockers(blocked_entrances, multiworld) + self.assertTrue(any(region in explored_regions for region in required_regions)) + + +def explore_regions_up_to_blockers(blocked_entrances: Collection[str], multiworld: MultiWorld) -> set[str]: + explored_regions: set[str] = set() + regions_by_name = multiworld.regions.region_cache[1] + regions_to_explore = deque([regions_by_name["Menu"]]) + + while regions_to_explore: + region = regions_to_explore.pop() + + if region.name in explored_regions: + continue + + explored_regions.add(region.name) + + for exit_ in region.exits: + exit_: Entrance + if exit_.name in blocked_entrances: + continue + regions_to_explore.append(exit_.connected_region) + + return explored_regions diff --git a/worlds/stardew_valley/test/regions/TestRandomizationFlag.py b/worlds/stardew_valley/test/regions/TestRandomizationFlag.py new file mode 100644 index 0000000000..6a01ef07e9 --- /dev/null +++ b/worlds/stardew_valley/test/regions/TestRandomizationFlag.py @@ -0,0 +1,88 @@ +import unittest + +from ..options.utils import fill_dataclass_with_default +from ... import create_content, options +from ...regions.entrance_rando import create_player_randomization_flag +from ...regions.model import RandomizationFlag, ConnectionData + + +class TestConnectionData(unittest.TestCase): + + def test_given_entrances_not_randomized_when_is_eligible_for_randomization_then_not_eligible(self): + player_flag = RandomizationFlag.NOT_RANDOMIZED + + connection = ConnectionData("Go to Somewhere", "Somewhere", RandomizationFlag.PELICAN_TOWN) + is_eligible = connection.is_eligible_for_randomization(player_flag) + + self.assertFalse(is_eligible) + + def test_given_pelican_town_connection_when_is_eligible_for_pelican_town_randomization_then_eligible(self): + player_flag = RandomizationFlag.BIT_PELICAN_TOWN + connection = ConnectionData("Go to Somewhere", "Somewhere", RandomizationFlag.PELICAN_TOWN) + + is_eligible = connection.is_eligible_for_randomization(player_flag) + + self.assertTrue(is_eligible) + + def test_given_pelican_town_connection_when_is_eligible_for_buildings_randomization_then_eligible(self): + player_flag = RandomizationFlag.BIT_BUILDINGS + connection = ConnectionData("Go to Somewhere", "Somewhere", RandomizationFlag.PELICAN_TOWN) + + is_eligible = connection.is_eligible_for_randomization(player_flag) + + self.assertTrue(is_eligible) + + def test_given_non_progression_connection_when_is_eligible_for_pelican_town_randomization_then_not_eligible(self): + player_flag = RandomizationFlag.BIT_PELICAN_TOWN + connection = ConnectionData("Go to Somewhere", "Somewhere", RandomizationFlag.NON_PROGRESSION) + + is_eligible = connection.is_eligible_for_randomization(player_flag) + + self.assertFalse(is_eligible) + + def test_given_non_progression_masteries_connection_when_is_eligible_for_non_progression_randomization_then_eligible(self): + player_flag = RandomizationFlag.BIT_NON_PROGRESSION + connection = ConnectionData("Go to Somewhere", "Somewhere", RandomizationFlag.NON_PROGRESSION ^ RandomizationFlag.EXCLUDE_MASTERIES) + + is_eligible = connection.is_eligible_for_randomization(player_flag) + + self.assertTrue(is_eligible) + + def test_given_non_progression_masteries_connection_when_is_eligible_for_non_progression_without_masteries_randomization_then_not_eligible(self): + player_flag = RandomizationFlag.BIT_NON_PROGRESSION | RandomizationFlag.EXCLUDE_MASTERIES + connection = ConnectionData("Go to Somewhere", "Somewhere", RandomizationFlag.NON_PROGRESSION ^ RandomizationFlag.EXCLUDE_MASTERIES) + + is_eligible = connection.is_eligible_for_randomization(player_flag) + + self.assertFalse(is_eligible) + + +class TestRandomizationFlag(unittest.TestCase): + + def test_given_entrance_randomization_choice_when_create_player_randomization_flag_then_only_relevant_bit_is_enabled(self): + for entrance_randomization_choice, expected_bit in ( + (options.EntranceRandomization.option_disabled, RandomizationFlag.NOT_RANDOMIZED), + (options.EntranceRandomization.option_pelican_town, RandomizationFlag.BIT_PELICAN_TOWN), + (options.EntranceRandomization.option_non_progression, RandomizationFlag.BIT_NON_PROGRESSION), + (options.EntranceRandomization.option_buildings_without_house, RandomizationFlag.BIT_BUILDINGS), + (options.EntranceRandomization.option_buildings, RandomizationFlag.BIT_BUILDINGS), + (options.EntranceRandomization.option_chaos, RandomizationFlag.BIT_BUILDINGS), + ): + player_options = fill_dataclass_with_default({options.EntranceRandomization: entrance_randomization_choice}) + content = create_content(player_options) + + flag = create_player_randomization_flag(player_options.entrance_randomization, content) + + self.assertEqual(flag, expected_bit) + + def test_given_masteries_not_randomized_when_create_player_randomization_flag_then_exclude_masteries_bit_enabled(self): + for entrance_randomization_choice in set(options.EntranceRandomization.options.values()) ^ {options.EntranceRandomization.option_disabled}: + player_options = fill_dataclass_with_default({ + options.EntranceRandomization: entrance_randomization_choice, + options.SkillProgression: options.SkillProgression.option_progressive + }) + content = create_content(player_options) + + flag = create_player_randomization_flag(player_options.entrance_randomization, content) + + self.assertIn(RandomizationFlag.EXCLUDE_MASTERIES, flag) diff --git a/worlds/stardew_valley/test/regions/TestRegionConnections.py b/worlds/stardew_valley/test/regions/TestRegionConnections.py new file mode 100644 index 0000000000..f20ef7943c --- /dev/null +++ b/worlds/stardew_valley/test/regions/TestRegionConnections.py @@ -0,0 +1,66 @@ +import unittest + +from ..options.utils import fill_dataclass_with_default +from ... import options +from ...content import create_content +from ...mods.region_data import region_data_by_content_pack +from ...regions import vanilla_data +from ...regions.model import MergeFlag +from ...regions.regions import create_all_regions, create_all_connections + + +class TestVanillaRegionsConnectionsWithGingerIsland(unittest.TestCase): + def test_region_exits_lead_somewhere(self): + for region in vanilla_data.regions_with_ginger_island_by_name.values(): + with self.subTest(region=region.name): + for exit_ in region.exits: + self.assertIn(exit_, vanilla_data.connections_with_ginger_island_by_name, + f"{region.name} is leading to {exit_} but it does not exist.") + + def test_connection_lead_somewhere(self): + for connection in vanilla_data.connections_with_ginger_island_by_name.values(): + with self.subTest(connection=connection.name): + self.assertIn(connection.destination, vanilla_data.regions_with_ginger_island_by_name, + f"{connection.name} is leading to {connection.destination} but it does not exist.") + + +class TestVanillaRegionsConnectionsWithoutGingerIsland(unittest.TestCase): + def test_region_exits_lead_somewhere(self): + for region in vanilla_data.regions_without_ginger_island_by_name.values(): + with self.subTest(region=region.name): + for exit_ in region.exits: + self.assertIn(exit_, vanilla_data.connections_without_ginger_island_by_name, + f"{region.name} is leading to {exit_} but it does not exist.") + + def test_connection_lead_somewhere(self): + for connection in vanilla_data.connections_without_ginger_island_by_name.values(): + with self.subTest(connection=connection.name): + self.assertIn(connection.destination, vanilla_data.regions_without_ginger_island_by_name, + f"{connection.name} is leading to {connection.destination} but it does not exist.") + + +class TestModsConnections(unittest.TestCase): + options = { + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false, + options.Mods: frozenset(options.Mods.valid_keys) + } + content = create_content(fill_dataclass_with_default(options)) + all_regions_by_name = create_all_regions(content.registered_packs) + all_connections_by_name = create_all_connections(content.registered_packs) + + def test_region_exits_lead_somewhere(self): + for mod_region_data in region_data_by_content_pack.values(): + for region in mod_region_data.regions: + if MergeFlag.REMOVE_EXITS in region.flag: + continue + + with self.subTest(mod=mod_region_data.mod_name, region=region.name): + for exit_ in region.exits: + self.assertIn(exit_, self.all_connections_by_name, f"{region.name} is leading to {exit_} but it does not exist.") + + def test_connection_lead_somewhere(self): + for mod_region_data in region_data_by_content_pack.values(): + for connection in mod_region_data.connections: + with self.subTest(mod=mod_region_data.mod_name, connection=connection.name): + self.assertIn(connection.destination, self.all_regions_by_name, + f"{connection.name} is leading to {connection.destination} but it does not exist.") diff --git a/worlds/stardew_valley/test/regions/__init__.py b/worlds/stardew_valley/test/regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/stardew_valley/test/rules/TestFishing.py b/worlds/stardew_valley/test/rules/TestFishing.py index 3649592301..22e6321a7a 100644 --- a/worlds/stardew_valley/test/rules/TestFishing.py +++ b/worlds/stardew_valley/test/rules/TestFishing.py @@ -8,7 +8,7 @@ class TestNeedRegionToCatchFish(SVTestBase): SeasonRandomization.internal_name: SeasonRandomization.option_disabled, ElevatorProgression.internal_name: ElevatorProgression.option_vanilla, SkillProgression.internal_name: SkillProgression.option_vanilla, - ToolProgression.internal_name: ToolProgression.option_vanilla, + ToolProgression.internal_name: ToolProgression.option_progressive, Fishsanity.internal_name: Fishsanity.option_all, ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_false, SpecialOrderLocations.internal_name: SpecialOrderLocations.option_board_qi, @@ -18,7 +18,7 @@ class TestNeedRegionToCatchFish(SVTestBase): fish_and_items = { Fish.crimsonfish: ["Beach Bridge"], Fish.void_salmon: ["Railroad Boulder Removed", "Dark Talisman"], - Fish.woodskip: ["Glittering Boulder Removed", "Progressive Weapon"], # For the ores to get the axe upgrades + Fish.woodskip: ["Progressive Axe", "Progressive Axe", "Progressive Weapon"], # For the ores to get the axe upgrades Fish.mutant_carp: ["Rusty Key"], Fish.slimejack: ["Railroad Boulder Removed", "Rusty Key"], Fish.lionfish: ["Boat Repair"], @@ -26,8 +26,8 @@ class TestNeedRegionToCatchFish(SVTestBase): Fish.stingray: ["Boat Repair", "Island Resort"], Fish.ghostfish: ["Progressive Weapon"], Fish.stonefish: ["Progressive Weapon"], - Fish.ice_pip: ["Progressive Weapon", "Progressive Weapon"], - Fish.lava_eel: ["Progressive Weapon", "Progressive Weapon", "Progressive Weapon"], + Fish.ice_pip: ["Progressive Weapon", "Progressive Weapon", "Progressive Pickaxe", "Progressive Pickaxe"], + Fish.lava_eel: ["Progressive Weapon", "Progressive Weapon", "Progressive Weapon", "Progressive Pickaxe", "Progressive Pickaxe", "Progressive Pickaxe"], Fish.sandfish: ["Bus Repair"], Fish.scorpion_carp: ["Desert Obelisk"], # Starting the extended family quest requires having caught all the legendaries before, so they all have the rules of every other legendary @@ -37,6 +37,7 @@ class TestNeedRegionToCatchFish(SVTestBase): Fish.legend_ii: ["Beach Bridge", "Island Obelisk", "Island West Turtle", "Qi Walnut Room", "Rusty Key"], Fish.ms_angler: ["Beach Bridge", "Island Obelisk", "Island West Turtle", "Qi Walnut Room", "Rusty Key"], } + self.collect("Progressive Fishing Rod", 4) self.original_state = self.multiworld.state.copy() for fish in fish_and_items: with self.subTest(f"Region rules for {fish}"): diff --git a/worlds/stardew_valley/test/rules/TestMuseum.py b/worlds/stardew_valley/test/rules/TestMuseum.py index 231bbafe22..1a22e8800c 100644 --- a/worlds/stardew_valley/test/rules/TestMuseum.py +++ b/worlds/stardew_valley/test/rules/TestMuseum.py @@ -1,12 +1,16 @@ from collections import Counter +from unittest.mock import patch from ..bases import SVTestBase -from ...options import Museumsanity +from ..options import presets +from ... import options, StardewLogic, StardewRule +from ...logic.museum_logic import MuseumLogic +from ...stardew_rule import true_, LiteralStardewRule class TestMuseumMilestones(SVTestBase): options = { - Museumsanity.internal_name: Museumsanity.option_milestones + options.Museumsanity: options.Museumsanity.option_milestones } def test_50_milestone(self): @@ -14,3 +18,45 @@ class TestMuseumMilestones(SVTestBase): milestone_rule = self.world.logic.museum.can_find_museum_items(50) self.assert_rule_false(milestone_rule, self.multiworld.state) + + +class DisabledMuseumRule(LiteralStardewRule): + value = False + + def __or__(self, other) -> StardewRule: + return other + + def __and__(self, other) -> StardewRule: + return self + + def __repr__(self): + return "Disabled Museum Rule" + + +class TestMuseumsanityDisabledExcludesMuseumDonationsFromOtherLocations(SVTestBase): + options = { + **presets.allsanity_mods_6_x_x(), + options.Museumsanity.internal_name: options.Museumsanity.option_none + } + + def test_museum_donations_are_never_required_in_any_locations(self): + with patch("worlds.stardew_valley.logic.museum_logic.MuseumLogic") as MockMuseumLogic: + museum_logic: MuseumLogic = MockMuseumLogic.return_value + museum_logic.can_donate_museum_items.return_value = DisabledMuseumRule() + museum_logic.can_donate_museum_artifacts.return_value = DisabledMuseumRule() + museum_logic.can_find_museum_artifacts.return_value = DisabledMuseumRule() + museum_logic.can_find_museum_minerals.return_value = DisabledMuseumRule() + museum_logic.can_find_museum_items.return_value = DisabledMuseumRule() + museum_logic.can_complete_museum.return_value = DisabledMuseumRule() + museum_logic.can_donate.return_value = DisabledMuseumRule() + # Allowing calls to museum rules since a lot of other logic depends on it, for minerals for instance. + museum_logic.can_find_museum_item.return_value = true_ + + regions = {region.name for region in self.multiworld.regions} + self.world.logic = StardewLogic(self.player, self.world.options, self.world.content, regions) + self.world.set_rules() + + self.collect_everything() + for location in self.get_real_locations(): + with self.subTest(location.name): + self.assert_can_reach_location(location) diff --git a/worlds/timespinner/Locations.py b/worlds/timespinner/Locations.py index 644304733a..21e5501e58 100644 --- a/worlds/timespinner/Locations.py +++ b/worlds/timespinner/Locations.py @@ -92,7 +92,7 @@ def get_location_datas(player: Optional[int], options: Optional[TimespinnerOptio LocationData('Military Fortress (hangar)', 'Military Fortress: Pedestal', 1337065, lambda state: state.has('Water Mask', player) if flooded.flood_lab else (logic.has_doublejump_of_npc(state) or logic.has_forwarddash_doublejump(state))), LocationData('The lab', 'Lab: Coffee break', 1337066), LocationData('The lab', 'Lab: Lower trash right', 1337067, logic.has_doublejump), - LocationData('The lab', 'Lab: Lower trash left', 1337068, lambda state: logic.has_doublejump_of_npc(state) if options.lock_key_amadeus else logic.has_upwarddash ), + LocationData('The lab', 'Lab: Lower trash left', 1337068, lambda state: logic.has_doublejump_of_npc(state) if options.lock_key_amadeus else logic.has_upwarddash(state) ), LocationData('The lab', 'Lab: Below lab entrance', 1337069, logic.has_doublejump), LocationData('The lab (power off)', 'Lab: Trash jump room', 1337070, lambda state: not options.lock_key_amadeus or logic.has_doublejump_of_npc(state) ), LocationData('The lab (power off)', 'Lab: Dynamo Works', 1337071, lambda state: not options.lock_key_amadeus or (state.has_all(('Lab Access Research', 'Lab Access Dynamo'), player)) ), @@ -100,7 +100,7 @@ def get_location_datas(player: Optional[int], options: Optional[TimespinnerOptio LocationData('The lab (power off)', 'Lab: Experiment #13', 1337073, lambda state: not options.lock_key_amadeus or state.has('Lab Access Experiment', player) ), LocationData('The lab (upper)', 'Lab: Download and chest room chest', 1337074), LocationData('The lab (upper)', 'Lab: Lab secret', 1337075, logic.can_break_walls), - LocationData('The lab (power off)', 'Lab: Spider Hell', 1337076, lambda state: logic.has_keycard_A and not options.lock_key_amadeus or state.has('Lab Access Research', player)), + LocationData('The lab (power off)', 'Lab: Spider Hell', 1337076, lambda state: logic.has_keycard_A(state) and not options.lock_key_amadeus or state.has('Lab Access Research', player)), LocationData('Emperors tower', 'Emperor\'s Tower: Courtyard bottom chest', 1337077), LocationData('Emperors tower', 'Emperor\'s Tower: Courtyard floor secret', 1337078, lambda state: logic.has_upwarddash(state) and logic.can_break_walls(state)), LocationData('Emperors tower', 'Emperor\'s Tower: Courtyard upper chest', 1337079, lambda state: logic.has_upwarddash(state)), @@ -150,10 +150,10 @@ def get_location_datas(player: Optional[int], options: Optional[TimespinnerOptio LocationData('Caves of Banishment (upper)', 'Caves of Banishment (Maw): Jackpot room chest 3', 1337118, lambda state: flooded.flood_maw or logic.has_forwarddash_doublejump(state)), LocationData('Caves of Banishment (upper)', 'Caves of Banishment (Maw): Jackpot room chest 4', 1337119, lambda state: flooded.flood_maw or logic.has_forwarddash_doublejump(state)), LocationData('Caves of Banishment (upper)', 'Caves of Banishment (Maw): Pedestal', 1337120, lambda state: not flooded.flood_maw or state.has('Water Mask', player)), - LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Last chance before Maw', 1337121, lambda state: state.has('Water Mask', player) if flooded.flood_maw else logic.has_doublejump(state)), - LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Plasma Crystal', 1337173, lambda state: state.has_any({'Gas Mask', 'Talaria Attachment'}, player) and (not flooded.flood_maw or state.has('Water Mask', player))), - LocationData('Caves of Banishment (Maw)', 'Killed Maw', EventId, lambda state: state.has('Gas Mask', player) and (not flooded.flood_maw or state.has('Water Mask', player))), - LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Mineshaft', 1337122, lambda state: state.has_any({'Gas Mask', 'Talaria Attachment'}, player) and (not flooded.flood_maw or state.has('Water Mask', player))), + LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Last chance before Maw', 1337121, lambda state: flooded.flood_maw or logic.has_doublejump(state)), + LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Plasma Crystal', 1337173, lambda state: state.has_any({'Gas Mask', 'Talaria Attachment'}, player)), + LocationData('Caves of Banishment (Maw)', 'Killed Maw', EventId, lambda state: state.has('Gas Mask', player)), + LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Mineshaft', 1337122, lambda state: state.has_any({'Gas Mask', 'Talaria Attachment'}, player)), LocationData('Caves of Banishment (Sirens)', 'Caves of Banishment (Sirens): Wyvern room', 1337123), LocationData('Caves of Banishment (Sirens)', 'Caves of Banishment (Sirens): Siren room above water chest', 1337124), LocationData('Caves of Banishment (Sirens)', 'Caves of Banishment (Sirens): Siren room underwater left chest', 1337125, lambda state: state.has('Water Mask', player)), @@ -251,7 +251,7 @@ def get_location_datas(player: Optional[int], options: Optional[TimespinnerOptio LocationData('Royal towers (upper)', 'Royal Towers: Journal - Top Struggle Juggle Base (War of the Sisters)', 1337195), LocationData('Royal towers (upper)', 'Royal Towers: Journal - Aelana Boss (Stained Letter)', 1337196), LocationData('Royal towers', 'Royal Towers: Journal - Near Bottom Struggle Juggle (Mission Findings)', 1337197, lambda state: flooded.flood_courtyard or logic.has_doublejump_of_npc(state)), - LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Journal - Lower Left Caves (Naivety)', 1337198, lambda state: not flooded.flood_maw or state.has('Water Mask', player)) + LocationData('Caves of Banishment (Maw)', 'Caves of Banishment (Maw): Journal - Lower Left Caves (Naivety)', 1337198) ) # 1337199 - 1337232 Reserved for future use diff --git a/worlds/timespinner/PreCalculatedWeights.py b/worlds/timespinner/PreCalculatedWeights.py index 3ad7c2c78a..96551ea7f1 100644 --- a/worlds/timespinner/PreCalculatedWeights.py +++ b/worlds/timespinner/PreCalculatedWeights.py @@ -88,12 +88,15 @@ class PreCalculatedWeights: if options.risky_warps: past_teleportation_gates.append("GateLakeSereneLeft") - present_teleportation_gates.append("GateDadsTower") if not is_xarion_flooded: present_teleportation_gates.append("GateXarion") - if not is_lab_flooded: - present_teleportation_gates.append("GateLabEntrance") + # Prevent going past the lazers without a way to the past + if options.unchained_keys or options.prism_break or not options.pyramid_start: + present_teleportation_gates.append("GateDadsTower") + if not is_lab_flooded: + present_teleportation_gates.append("GateLabEntrance") + # Prevent getting stuck in the past without a way back to the future if options.inverted or (options.pyramid_start and not options.back_to_the_future): all_gates: Tuple[str, ...] = present_teleportation_gates else: diff --git a/worlds/timespinner/Regions.py b/worlds/timespinner/Regions.py index 51b1688f1a..b9b1d10445 100644 --- a/worlds/timespinner/Regions.py +++ b/worlds/timespinner/Regions.py @@ -115,7 +115,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, options: Timesp connect(world, player, 'The lab', 'The lab (power off)', lambda state: options.lock_key_amadeus or logic.has_doublejump_of_npc(state)) connect(world, player, 'The lab (power off)', 'The lab', lambda state: not flooded.flood_lab or state.has('Water Mask', player)) connect(world, player, 'The lab (power off)', 'The lab (upper)', lambda state: logic.has_forwarddash_doublejump(state) and ((not options.lock_key_amadeus) or state.has('Lab Access Genza', player))) - connect(world, player, 'The lab (upper)', 'The lab (power off)') + connect(world, player, 'The lab (upper)', 'The lab (power off)', lambda state: options.lock_key_amadeus and state.has('Lab Access Genza', player)) connect(world, player, 'The lab (upper)', 'Emperors tower', logic.has_forwarddash_doublejump) connect(world, player, 'The lab (upper)', 'Ancient Pyramid (entrance)', lambda state: state.has_all({'Timespinner Wheel', 'Timespinner Spindle', 'Timespinner Gear 1', 'Timespinner Gear 2', 'Timespinner Gear 3'}, player)) connect(world, player, 'Emperors tower', 'The lab (upper)') @@ -141,7 +141,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, options: Timesp connect(world, player, 'Lower Lake Serene', 'Left Side forest Caves') connect(world, player, 'Lower Lake Serene', 'Caves of Banishment (upper)', lambda state: flooded.flood_lake_serene or logic.has_doublejump(state)) connect(world, player, 'Caves of Banishment (upper)', 'Lower Lake Serene', lambda state: not flooded.flood_lake_serene or state.has('Water Mask', player)) - connect(world, player, 'Caves of Banishment (upper)', 'Caves of Banishment (Maw)', lambda state: logic.has_doublejump(state) or state.has_any({'Gas Mask', 'Talaria Attachment'} or logic.has_teleport(state), player)) + connect(world, player, 'Caves of Banishment (upper)', 'Caves of Banishment (Maw)', lambda state: not flooded.flood_maw or state.has('Water Mask', player)) connect(world, player, 'Caves of Banishment (upper)', 'Space time continuum', logic.has_teleport) connect(world, player, 'Caves of Banishment (Maw)', 'Caves of Banishment (upper)', lambda state: logic.has_doublejump(state) if not flooded.flood_maw else state.has('Water Mask', player)) connect(world, player, 'Caves of Banishment (Maw)', 'Caves of Banishment (Sirens)', lambda state: state.has_any({'Gas Mask', 'Talaria Attachment'}, player) ) @@ -178,7 +178,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, options: Timesp connect(world, player, 'Space time continuum', 'Upper Lake Serene', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneLeft")) connect(world, player, 'Space time continuum', 'Left Side forest Caves', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneRight")) connect(world, player, 'Space time continuum', 'Refugee Camp', lambda state: logic.can_teleport_to(state, "Past", "GateAccessToPast")) - connect(world, player, 'Space time continuum', 'Castle Ramparts', lambda state: logic.can_teleport_to(state, "Past", "GateCastleRamparts")) + connect(world, player, 'Space time continuum', 'Forest', lambda state: logic.can_teleport_to(state, "Past", "GateCastleRamparts")) connect(world, player, 'Space time continuum', 'Castle Keep', lambda state: logic.can_teleport_to(state, "Past", "GateCastleKeep")) connect(world, player, 'Space time continuum', 'Royal towers (lower)', lambda state: logic.can_teleport_to(state, "Past", "GateRoyalTowers")) connect(world, player, 'Space time continuum', 'Caves of Banishment (Maw)', lambda state: logic.can_teleport_to(state, "Past", "GateMaw")) diff --git a/worlds/timespinner/__init__.py b/worlds/timespinner/__init__.py index 4d1efc41e5..77314d40ec 100644 --- a/worlds/timespinner/__init__.py +++ b/worlds/timespinner/__init__.py @@ -42,6 +42,7 @@ class TimespinnerWorld(World): topology_present = True web = TimespinnerWebWorld() required_client_version = (0, 4, 2) + ut_can_gen_without_yaml = True item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = {location.name: location.code for location in get_location_datas(-1, None, None)} diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 8c0979e3e4..edd6021cba 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -56,18 +56,18 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ for portal1, portal2 in portal_pairs.items(): if portal1.scene_destination() == portal_sd: return portal1.name, get_portal_outlet_region(portal2, world) - if portal2.scene_destination() == portal_sd: + if portal2.scene_destination() == portal_sd and not (options.decoupled and options.entrance_rando): return portal2.name, get_portal_outlet_region(portal1, world) - raise Exception("No matches found in get_portal_info") + raise Exception(f"No matches found in get_portal_info for {portal_sd}") # input scene destination tag, returns paired portal's name and region def get_paired_portal(portal_sd: str) -> Tuple[str, str]: for portal1, portal2 in portal_pairs.items(): if portal1.scene_destination() == portal_sd: return portal2.name, portal2.region - if portal2.scene_destination() == portal_sd: + if portal2.scene_destination() == portal_sd and not (options.decoupled and options.entrance_rando): return portal1.name, portal1.region - raise Exception("no matches found in get_paired_portal") + raise Exception(f"No matches found in get_paired_portal for {portal_sd}") regions["Menu"].connect( connecting_region=regions["Overworld"]) diff --git a/worlds/tunic/test/__init__.py b/worlds/tunic/test/__init__.py index d0b68955c5..e69de29bb2 100644 --- a/worlds/tunic/test/__init__.py +++ b/worlds/tunic/test/__init__.py @@ -1,6 +0,0 @@ -from test.bases import WorldTestBase - - -class TunicTestBase(WorldTestBase): - game = "TUNIC" - player = 1 diff --git a/worlds/tunic/test/bases.py b/worlds/tunic/test/bases.py new file mode 100644 index 0000000000..0e51bcd013 --- /dev/null +++ b/worlds/tunic/test/bases.py @@ -0,0 +1,5 @@ +from test.bases import WorldTestBase + + +class TunicTestBase(WorldTestBase): + game = "TUNIC" diff --git a/worlds/tunic/test/test_access.py b/worlds/tunic/test/test_access.py index 6a26180cf0..1896db5d13 100644 --- a/worlds/tunic/test/test_access.py +++ b/worlds/tunic/test/test_access.py @@ -1,5 +1,5 @@ -from . import TunicTestBase from .. import options +from .bases import TunicTestBase class TestAccess(TunicTestBase): diff --git a/worlds/tunic/test/test_combat.py b/worlds/tunic/test/test_combat.py index c0e76ef92b..70324247df 100644 --- a/worlds/tunic/test/test_combat.py +++ b/worlds/tunic/test/test_combat.py @@ -1,17 +1,15 @@ from BaseClasses import ItemClassification from collections import Counter -from . import TunicTestBase -from .. import options +from .. import options, TunicWorld +from .bases import TunicTestBase from ..combat_logic import (check_combat_reqs, area_data, get_money_count, calc_effective_hp, get_potion_level, get_hp_level, get_def_level, get_sp_level, has_combat_reqs) from ..items import item_table -from .. import TunicWorld class TestCombat(TunicTestBase): options = {options.CombatLogic.internal_name: options.CombatLogic.option_on} - player = 1 world: TunicWorld combat_items = [] # these are items that are progression that do not contribute to combat logic diff --git a/worlds/tunic/ut_stuff.py b/worlds/tunic/ut_stuff.py index 8296452c73..2cf2f96a4f 100644 --- a/worlds/tunic/ut_stuff.py +++ b/worlds/tunic/ut_stuff.py @@ -67,99 +67,6 @@ def map_page_index(data: Any) -> int: # mapping of everything after the second to last slash and the location id # lua used for the name: string.match(full_name, "[^/]*/[^/]*$") poptracker_data: dict[str, int] = { - "[Powered Secret Room] Chest/Follow the Purple Energy Road": 509342400, - "[Entryway] Chest/Mind the Slorms": 509342401, - "[Third Room] Beneath Platform Chest/Run from the tentacles!": 509342402, - "[Third Room] Tentacle Chest/Water Sucks": 509342403, - "[Entryway] Obscured Behind Waterfall/You can just go in there": 509342404, - "[Save Room] Upper Floor Chest 1/Through the Power of Prayer": 509342405, - "[Save Room] Upper Floor Chest 2/Above the Fox Shrine": 509342406, - "[Second Room] Underwater Chest/Hidden Passage": 509342407, - "[Back Corridor] Right Secret/Hidden Path": 509342408, - "[Back Corridor] Left Secret/Behind the Slorms": 509342409, - "[Second Room] Obscured Behind Waterfall/Just go in there": 509342410, - "[Side Room] Chest By Pots/Just Climb up There": 509342411, - "[Side Room] Chest By Phrends/So Many Phrends!": 509342412, - "[Second Room] Page/Ruined Atoll Map": 509342413, - "[Passage To Dark Tomb] Page Pickup/Siege Engine": 509342414, - "[1F] Guarded By Lasers/Beside 3 Miasma Seekers": 509342415, - "[1F] Near Spikes/Mind the Miasma Seeker": 509342416, - "Birdcage Room/[2F] Bird Room": 509342417, - "[2F] Entryway Upper Walkway/Overlooking Miasma": 509342418, - "[1F] Library/By the Books": 509342419, - "[2F] Library/Behind the Ladder": 509342420, - "[2F] Guarded By Lasers/Before the big reveal...": 509342421, - "Birdcage Room/[2F] Bird Room Secret": 509342422, - "[1F] Library Secret/Pray to the Wallman": 509342423, - "Spike Maze Near Exit/Watch out!": 509342424, - "2nd Laser Room/Can you roll?": 509342425, - "1st Laser Room/Use a bomb?": 509342426, - "Spike Maze Upper Walkway/Just walk right!": 509342427, - "Skulls Chest/Move the Grave": 509342428, - "Spike Maze Near Stairs/In the Corner": 509342429, - "1st Laser Room Obscured/Follow the red laser of death": 509342430, - "Guardhouse 2 - Upper Floor/In the Mound": 509342431, - "Guardhouse 2 - Bottom Floor Secret/Hidden Hallway": 509342432, - "Guardhouse 1 Obscured/Upper Floor Obscured": 509342433, - "Guardhouse 1/Upper Floor": 509342434, - "Guardhouse 1 Ledge HC/Dancing Fox Spirit Holy Cross": 509342435, - "Golden Obelisk Holy Cross/Use the Holy Cross": 509342436, - "Ice Rod Grapple Chest/Freeze the Blob and ascend With Orb": 509342437, - "Above Save Point/Chest": 509342438, - "Above Save Point Obscured/Hidden Path": 509342439, - "Guardhouse 1 Ledge/From Guardhouse 1 Chest": 509342440, - "Near Save Point/Chest": 509342441, - "Ambushed by Spiders/Beneath Spider Chest": 509342442, - "Near Telescope/Up on the Wall": 509342443, - "Ambushed by Spiders/Spider Chest": 509342444, - "Lower Dash Chest/Dash Across": 509342445, - "Lower Grapple Chest/Grapple Across": 509342446, - "Bombable Wall/Follow the Flowers": 509342447, - "Page On Teleporter/Page": 509342448, - "Forest Belltower Save Point/Near Save Point": 509342449, - "Forest Belltower - After Guard Captain/Chest": 509342450, - "East Bell/Forest Belltower - Obscured Near Bell Top Floor": 509342451, - "Forest Belltower Obscured/Obscured Beneath Bell Bottom Floor": 509342452, - "Forest Belltower Page/Page Pickup": 509342453, - "Forest Grave Path - Holy Cross Code by Grave/Single Money Chest": 509342454, - "Forest Grave Path - Above Gate/Chest": 509342455, - "Forest Grave Path - Obscured Chest/Behind the Trees": 509342456, - "Forest Grave Path - Upper Walkway/From the top of the Guardhouse": 509342457, - "The Hero's Sword/Forest Grave Path - Sword Pickup": 509342458, - "The Hero's Sword/Hero's Grave - Tooth Relic": 509342459, - "Fortress Courtyard - From East Belltower/Crack in the Wall": 509342460, - "Fortress Leaf Piles - Secret Chest/Dusty": 509342461, - "Fortress Arena/Hexagon Red": 509342462, - "Fortress Arena/Siege Engine|Vault Key Pickup": 509342463, - "Fortress East Shortcut - Chest Near Slimes/Mind the Custodians": 509342464, - "[West Wing] Candles Holy Cross/Use the Holy Cross": 509342465, - "Westmost Upper Room/[West Wing] Dark Room Chest 1": 509342466, - "Westmost Upper Room/[West Wing] Dark Room Chest 2": 509342467, - "[East Wing] Bombable Wall/Bomb the Wall": 509342468, - "[West Wing] Page Pickup/He will never visit the Far Shore": 509342469, - "Fortress Grave Path - Upper Walkway/Go Around the East Wing": 509342470, - "Vault Hero's Grave/Fortress Grave Path - Chest Right of Grave": 509342471, - "Vault Hero's Grave/Fortress Grave Path - Obscured Chest Left of Grave": 509342472, - "Vault Hero's Grave/Hero's Grave - Flowers Relic": 509342473, - "Bridge/Chest": 509342474, - "Cell Chest 1/Drop the Shortcut Rope": 509342475, - "Obscured Behind Waterfall/Muffling Bell": 509342476, - "Back Room Chest/Lose the Lure or take 2 Damage": 509342477, - "Cell Chest 2/Mind the Custodian": 509342478, - "Near Vault/Already Stolen": 509342479, - "Slorm Room/Tobias was Trapped Here Once...": 509342480, - "Escape Chest/Don't Kick Fimbleton!": 509342481, - "Grapple Above Hot Tub/Look Up": 509342482, - "Above Vault/Obscured Doorway Ledge": 509342483, - "Main Room Top Floor/Mind the Adult Frog": 509342484, - "Main Room Bottom Floor/Altar Chest": 509342485, - "Side Room Secret Passage/Upper Right Corner": 509342486, - "Side Room Chest/Oh No! Our Frogs! They're Dead!": 509342487, - "Side Room Grapple Secret/Grapple on Over": 509342488, - "Magic Orb Pickup/Frult Meeting": 509342489, - "The Librarian/Hexagon Green": 509342490, - "Library Hall/Holy Cross Chest": 509342491, - "Library Lab Chest by Shrine 2/Chest": 509342492, "Library Lab Chest by Shrine 1/Chest": 509342493, "Library Lab Chest by Shrine 3/Chest": 509342494, "Library Lab by Fuse/Behind Chalkboard": 509342495, @@ -369,13 +276,412 @@ poptracker_data: dict[str, int] = { "[North] Page Pickup/Survival Tips": 509342699, "[Southeast Lowlands] Ice Dagger Pickup/Ice Dagger Cave": 509342700, "Hero's Grave/Effigy Relic": 509342701, + "[East] Bombable Wall/Break Bombable Wall": 509350705, + "[West] Upper Area Bombable Wall/Break Bombable Wall": 509350704, + "[East Wing] Bombable Wall/Break Bombable Wall": 509350703, + "Bombable Wall/Break Bombable Wall": 509350702, + "[Northwest] Bombable Wall/Break Bombable Wall": 509350701, + "[Southwest] Bombable Wall Near Fountain/Break Bombable Wall": 509350700, + "Cube Cave/Break Bombable Wall": 509350699, + "[Central] Bombable Wall/Break Bombable Wall": 509350698, + "Purgatory Pots/Pot 33": 509350697, + "Purgatory Pots/Pot 32": 509350696, + "Purgatory Pots/Pot 31": 509350695, + "Purgatory Pots/Pot 30": 509350694, + "Purgatory Pots/Pot 29": 509350693, + "Purgatory Pots/Pot 28": 509350692, + "Purgatory Pots/Pot 27": 509350691, + "Purgatory Pots/Pot 26": 509350690, + "Purgatory Pots/Pot 25": 509350689, + "Purgatory Pots/Pot 24": 509350688, + "Purgatory Pots/Pot 23": 509350687, + "Purgatory Pots/Pot 22": 509350686, + "Purgatory Pots/Pot 21": 509350685, + "Purgatory Pots/Pot 20": 509350684, + "Purgatory Pots/Pot 19": 509350683, + "Purgatory Pots/Pot 18": 509350682, + "Purgatory Pots/Pot 17": 509350681, + "Purgatory Pots/Pot 16": 509350680, + "Purgatory Pots/Pot 15": 509350679, + "Purgatory Pots/Pot 14": 509350678, + "Purgatory Pots/Pot 13": 509350677, + "Purgatory Pots/Pot 12": 509350676, + "Purgatory Pots/Pot 11": 509350675, + "Purgatory Pots/Pot 10": 509350674, + "Purgatory Pots/Pot 9": 509350673, + "Purgatory Pots/Pot 8": 509350672, + "Purgatory Pots/Pot 7": 509350671, + "Purgatory Pots/Pot 6": 509350670, + "Purgatory Pots/Pot 5": 509350669, + "Purgatory Pots/Pot 4": 509350668, + "Purgatory Pots/Pot 3": 509350667, + "Purgatory Pots/Pot 2": 509350666, + "Purgatory Pots/Pot 1": 509350665, + "[1F] Pots by Stairs/Pot 2": 509350664, + "[1F] Pots by Stairs/Pot 1": 509350663, + "Crates/Crate 9": 509350662, + "Crates/Crate 8": 509350661, + "Crates/Crate 7": 509350660, + "Crates/Crate 6": 509350659, + "Crates/Crate 5": 509350658, + "Crates/Crate 4": 509350657, + "Crates/Crate 3": 509350656, + "Crates/Crate 2": 509350655, + "Crates/Crate 1": 509350654, + "[Lowlands] Crates/Crate 2": 509350653, + "[Lowlands] Crates/Crate 1": 509350652, + "[West] Near Isolated Chest/Crate 5": 509350651, + "[West] Near Isolated Chest/Crate 4": 509350650, + "[West] Near Isolated Chest/Crate 3": 509350649, + "[West] Near Isolated Chest/Crate 2": 509350648, + "[West] Near Isolated Chest/Crate 1": 509350647, + "[West] Crates by Shooting Range/Crate 5": 509350646, + "[West] Crates by Shooting Range/Crate 4": 509350645, + "[West] Crates by Shooting Range/Crate 3": 509350644, + "[West] Crates by Shooting Range/Crate 2": 509350643, + "[West] Crates by Shooting Range/Crate 1": 509350642, + "[West] Near Isolated Chest/Explosive Pot 2": 509350641, + "[West] Near Isolated Chest/Explosive Pot 1": 509350640, + "[West] Explosive Pot above Shooting Range/Explosive Pot": 509350639, + "[West] Explosive Pots near Bombable Wall/Explosive Pot 2": 509350638, + "[West] Explosive Pots near Bombable Wall/Explosive Pot 1": 509350637, + "[Central] Crates near Shortcut Ladder/Crate 5": 509350636, + "[Central] Crates near Shortcut Ladder/Crate 4": 509350635, + "[Central] Crates near Shortcut Ladder/Crate 3": 509350634, + "[Central] Crates near Shortcut Ladder/Crate 2": 509350633, + "[Central] Crates near Shortcut Ladder/Crate 1": 509350632, + "[Central] Explosive Pots near Shortcut Ladder/Explosive Pot 2": 509350631, + "[Central] Explosive Pots near Shortcut Ladder/Explosive Pot 1": 509350630, + "[Back Entrance] Pots/Pot 5": 509350629, + "[Back Entrance] Pots/Pot 4": 509350628, + "[Back Entrance] Pots/Pot 3": 509350627, + "[Back Entrance] Pots/Pot 2": 509350626, + "[Back Entrance] Pots/Pot 1": 509350625, + "[Central] Explosive Pots near Monastery/Explosive Pot 2": 509350624, + "[Central] Explosive Pots near Monastery/Explosive Pot 1": 509350623, + "[East] Explosive Pot beneath Scaffolding/Explosive Pot": 509350622, + "[East] Explosive Pots/Explosive Pot 3": 509350621, + "[East] Explosive Pots/Explosive Pot 2": 509350620, + "[East] Explosive Pots/Explosive Pot 1": 509350619, + "Display Cases/Display Case 3": 509350618, + "Display Cases/Display Case 2": 509350617, + "Display Cases/Display Case 1": 509350616, + "Orb Room Explosive Pots/Explosive Pot 2": 509350615, + "Orb Room Explosive Pots/Explosive Pot 1": 509350614, + "Pots after Gate/Pot 2": 509350613, + "Pots after Gate/Pot 1": 509350612, + "Slorm Room/Pot": 509350611, + "Main Room Pots/Pot 2": 509350610, + "Main Room Pots/Pot 1": 509350609, + "Side Room Pots/Pot 3": 509350608, + "Side Room Pots/Pot 2": 509350607, + "Side Room Pots/Pot 1": 509350606, + "Pots above Orb Altar/Pot 2": 509350605, + "Pots above Orb Altar/Pot 1": 509350604, + "[Upper] Pots/Pot 6": 509350603, + "[Upper] Pots/Pot 5": 509350602, + "[Upper] Pots/Pot 4": 509350601, + "[Upper] Pots/Pot 3": 509350600, + "[Upper] Pots/Pot 2": 509350599, + "[Upper] Pots/Pot 1": 509350598, + "[South] Explosive Pot near Birds/Explosive Pot": 509350597, + "[West] Broken House/Table": 509350596, + "[West] Broken House/Pot 2": 509350595, + "[West] Broken House/Pot 1": 509350594, + "Fortress Arena/Pot 2": 509350593, + "Fortress Arena/Pot 1": 509350592, + "Fortress Leaf Piles - Secret Chest/Leaf Pile 4": 509350591, + "Fortress Leaf Piles - Secret Chest/Leaf Pile 3": 509350590, + "Fortress Leaf Piles - Secret Chest/Leaf Pile 2": 509350589, + "Fortress Leaf Piles - Secret Chest/Leaf Pile 1": 509350588, + "Barrels/Back Room Barrel 7": 509350587, + "Barrels/Back Room Barrel 6": 509350586, + "Barrels/Back Room Barrel 5": 509350585, + "[Northwest] Sign by Quarry Gate/Sign": 509350400, + "[Central] Sign South of Checkpoint/Sign": 509350401, + "[Central] Sign by Ruined Passage/Sign": 509350402, + "[East] Pots near Slimes/Pot 1": 509350403, + "[East] Pots near Slimes/Pot 2": 509350404, + "[East] Pots near Slimes/Pot 3": 509350405, + "[East] Pots near Slimes/Pot 4": 509350406, + "[East] Pots near Slimes/Pot 5": 509350407, + "[East] Forest Sign/Sign": 509350408, + "[East] Fortress Sign/Sign": 509350409, + "[North] Pots/Pot 1": 509350410, + "[North] Pots/Pot 2": 509350411, + "[North] Pots/Pot 3": 509350412, + "[North] Pots/Pot 4": 509350413, + "[West] Sign Near West Garden Entrance/Sign": 509350414, + "Stick House/Pot 1": 509350415, + "Stick House/Pot 2": 509350416, + "Stick House/Pot 3": 509350417, + "Stick House/Pot 4": 509350418, + "Stick House/Pot 5": 509350419, + "Stick House/Pot 6": 509350420, + "Stick House/Pot 7": 509350421, + "Ruined Shop/Pot 1": 509350422, + "Ruined Shop/Pot 2": 509350423, + "Ruined Shop/Pot 3": 509350424, + "Ruined Shop/Pot 4": 509350425, + "Ruined Shop/Pot 5": 509350426, + "Inside Hourglass Cave/Sign": 509350427, + "Pots by Slimes/Pot 1": 509350428, + "Pots by Slimes/Pot 2": 509350429, + "Pots by Slimes/Pot 3": 509350430, + "Pots by Slimes/Pot 4": 509350431, + "Pots by Slimes/Pot 5": 509350432, + "Pots by Slimes/Pot 6": 509350433, + "[Upper] Barrels/Barrel 1": 509350434, + "[Upper] Barrels/Barrel 2": 509350435, + "[Upper] Barrels/Barrel 3": 509350436, + "Pots after Guard Captain/Pot 1": 509350437, + "Pots after Guard Captain/Pot 2": 509350438, + "Pots after Guard Captain/Pot 3": 509350439, + "Pots after Guard Captain/Pot 4": 509350440, + "Pots after Guard Captain/Pot 5": 509350441, + "Pots after Guard Captain/Pot 6": 509350442, + "Pots after Guard Captain/Pot 7": 509350443, + "Pots after Guard Captain/Pot 8": 509350444, + "Pots after Guard Captain/Pot 9": 509350445, + "Pots/Pot 1": 509350446, + "Pots/Pot 2": 509350447, + "Pots/Pot 3": 509350448, + "Pots/Pot 4": 509350449, + "Pots/Pot 5": 509350450, + "Sign by Grave Path/Sign": 509350451, + "Sign by Guardhouse 1/Sign": 509350452, + "Pots by Grave Path/Pot 1": 509350453, + "Pots by Grave Path/Pot 2": 509350454, + "Pots by Grave Path/Pot 3": 509350455, + "Pots by Envoy/Pot 1": 509350456, + "Pots by Envoy/Pot 2": 509350457, + "Pots by Envoy/Pot 3": 509350458, + "Bottom Floor Pots/Pot 1": 509350459, + "Bottom Floor Pots/Pot 2": 509350460, + "Bottom Floor Pots/Pot 3": 509350461, + "Bottom Floor Pots/Pot 4": 509350462, + "Bottom Floor Pots/Pot 5": 509350463, + "[Side Room] Pots by Chest/Pot 1": 509350464, + "[Side Room] Pots by Chest/Pot 2": 509350465, + "[Side Room] Pots by Chest/Pot 3": 509350466, + "[Third Room] Barrels by Bridge/Barrel 1": 509350467, + "[Third Room] Barrels by Bridge/Barrel 2": 509350468, + "[Third Room] Barrels by Bridge/Barrel 3": 509350469, + "[Third Room] Barrels after Back Corridor/Barrel 1": 509350470, + "[Third Room] Barrels after Back Corridor/Barrel 2": 509350471, + "[Third Room] Barrels after Back Corridor/Barrel 3": 509350472, + "[Third Room] Barrels after Back Corridor/Barrel 4": 509350473, + "[Third Room] Barrels after Back Corridor/Barrel 5": 509350474, + "[Third Room] Barrels by West Turret/Barrel 1": 509350475, + "[Third Room] Barrels by West Turret/Barrel 2": 509350476, + "[Third Room] Barrels by West Turret/Barrel 3": 509350477, + "[Third Room] Pots by East Turret/Pot 1": 509350478, + "[Third Room] Pots by East Turret/Pot 2": 509350479, + "[Third Room] Pots by East Turret/Pot 3": 509350480, + "[Third Room] Pots by East Turret/Pot 4": 509350481, + "[Third Room] Pots by East Turret/Pot 5": 509350482, + "[Third Room] Pots by East Turret/Pot 6": 509350483, + "[Third Room] Pots by East Turret/Pot 7": 509350484, + "Barrels/Barrel 1": 509350485, + "Barrels/Barrel 2": 509350486, + "Pot Hallway Pots/Pot 1": 509350487, + "Pot Hallway Pots/Pot 2": 509350488, + "Pot Hallway Pots/Pot 3": 509350489, + "Pot Hallway Pots/Pot 4": 509350490, + "Pot Hallway Pots/Pot 5": 509350491, + "Pot Hallway Pots/Pot 6": 509350492, + "Pot Hallway Pots/Pot 7": 509350493, + "Pot Hallway Pots/Pot 8": 509350494, + "Pot Hallway Pots/Pot 9": 509350495, + "Pot Hallway Pots/Pot 10": 509350496, + "Pot Hallway Pots/Pot 11": 509350497, + "Pot Hallway Pots/Pot 12": 509350498, + "Pot Hallway Pots/Pot 13": 509350499, + "Pot Hallway Pots/Pot 14": 509350500, + "2nd Laser Room Pots/Pot 1": 509350501, + "2nd Laser Room Pots/Pot 2": 509350502, + "2nd Laser Room Pots/Pot 3": 509350503, + "2nd Laser Room Pots/Pot 4": 509350504, + "2nd Laser Room Pots/Pot 5": 509350505, + "[Southeast Lowlands] Ice Dagger Pickup/Pot 1": 509350506, + "[Southeast Lowlands] Ice Dagger Pickup/Pot 2": 509350507, + "[Southeast Lowlands] Ice Dagger Pickup/Pot 3": 509350508, + "Fire Pots/Fire Pot 1": 509350509, + "Fire Pots/Fire Pot 2": 509350510, + "Fire Pots/Fire Pot 3": 509350511, + "Fire Pots/Fire Pot 4": 509350512, + "Fire Pots/Fire Pot 5": 509350513, + "Fire Pots/Fire Pot 6": 509350514, + "Fire Pots/Fire Pot 7": 509350515, + "Fire Pots/Fire Pot 8": 509350516, + "Upper Fire Pot/Fire Pot": 509350517, + "[Entry] Pots/Pot 1": 509350518, + "[Entry] Pots/Pot 2": 509350519, + "[By Grave] Pots/Pot 1": 509350520, + "[By Grave] Pots/Pot 2": 509350521, + "[By Grave] Pots/Pot 3": 509350522, + "[By Grave] Pots/Pot 4": 509350523, + "[By Grave] Pots/Pot 5": 509350524, + "[By Grave] Pots/Pot 6": 509350525, + "[Central] Fire Pots/Fire Pot 1": 509350526, + "[Central] Fire Pots/Fire Pot 2": 509350527, + "[Central] Pots by Door/Pot 1": 509350528, + "[Central] Pots by Door/Pot 2": 509350529, + "[Central] Pots by Door/Pot 3": 509350530, + "[Central] Pots by Door/Pot 4": 509350531, + "[Central] Pots by Door/Pot 5": 509350532, + "[Central] Pots by Door/Pot 6": 509350533, + "[Central] Pots by Door/Pot 7": 509350534, + "[Central] Pots by Door/Pot 8": 509350535, + "[Central] Pots by Door/Pot 9": 509350536, + "[Central] Pots by Door/Pot 10": 509350537, + "[Central] Pots by Door/Pot 11": 509350538, + "[East Wing] Pots by Broken Checkpoint/Pot 1": 509350539, + "[East Wing] Pots by Broken Checkpoint/Pot 2": 509350540, + "[East Wing] Pots by Broken Checkpoint/Pot 3": 509350541, + "[West Wing] Pots by Checkpoint/Pot 1": 509350542, + "[West Wing] Pots by Checkpoint/Pot 2": 509350543, + "[West Wing] Pots by Checkpoint/Pot 3": 509350544, + "[West Wing] Pots by Overlook/Pot 1": 509350545, + "[West Wing] Pots by Overlook/Pot 2": 509350546, + "[West Wing] Slorm Room Pots/Pot 1": 509350547, + "[West Wing] Slorm Room Pots/Pot 2": 509350548, + "[West Wing] Slorm Room Pots/Pot 3": 509350549, + "[West Wing] Chest Room Pots/Pot 1": 509350550, + "[West Wing] Chest Room Pots/Pot 2": 509350551, + "[West Wing] Pots by Stairs to Basement/Pot 1": 509350552, + "[West Wing] Pots by Stairs to Basement/Pot 2": 509350553, + "[West Wing] Pots by Stairs to Basement/Pot 3": 509350554, + "Entry Spot/Pot 1": 509350555, + "Entry Spot/Pot 2": 509350556, + "Entry Spot/Crate 1": 509350557, + "Entry Spot/Crate 2": 509350558, + "Entry Spot/Crate 3": 509350559, + "Entry Spot/Crate 4": 509350560, + "Entry Spot/Crate 5": 509350561, + "Entry Spot/Crate 6": 509350562, + "Entry Spot/Crate 7": 509350563, + "Slorm Room Crates/Crate 1": 509350564, + "Slorm Room Crates/Crate 2": 509350565, + "Crates under Rope/Crate 1": 509350566, + "Crates under Rope/Crate 2": 509350567, + "Crates under Rope/Crate 3": 509350568, + "Crates under Rope/Crate 4": 509350569, + "Crates under Rope/Crate 5": 509350570, + "Crates under Rope/Crate 6": 509350571, + "Fuse Room Fire Pots/Fire Pot 1": 509350572, + "Fuse Room Fire Pots/Fire Pot 2": 509350573, + "Fuse Room Fire Pots/Fire Pot 3": 509350574, + "Barrels/Barrel by Back Room 1": 509350575, + "Barrels/Barrel by Back Room 2": 509350576, + "Barrels/Barrel by Back Room 3": 509350577, + "Barrels/Barrel by Back Room 4": 509350578, + "Barrels/Barrel by Back Room 5": 509350579, + "Barrels/Barrel by Back Room 6": 509350580, + "Barrels/Back Room Barrel 1": 509350581, + "Barrels/Back Room Barrel 2": 509350582, + "Barrels/Back Room Barrel 3": 509350583, + "[Powered Secret Room] Chest/Follow the Purple Energy Road": 509342400, + "[Entryway] Chest/Mind the Slorms": 509342401, + "[Third Room] Beneath Platform Chest/Run from the tentacles!": 509342402, + "[Third Room] Tentacle Chest/Water Sucks": 509342403, + "[Entryway] Obscured Behind Waterfall/You can just go in there": 509342404, + "[Save Room] Upper Floor Chest 1/Through the Power of Prayer": 509342405, + "[Save Room] Upper Floor Chest 2/Above the Fox Shrine": 509342406, + "[Second Room] Underwater Chest/Hidden Passage": 509342407, + "[Back Corridor] Right Secret/Hidden Path": 509342408, + "[Back Corridor] Left Secret/Behind the Slorms": 509342409, + "[Second Room] Obscured Behind Waterfall/Just go in there": 509342410, + "[Side Room] Chest By Pots/Just Climb up There": 509342411, + "[Side Room] Chest By Phrends/So Many Phrends!": 509342412, + "[Second Room] Page/Ruined Atoll Map": 509342413, + "[Passage To Dark Tomb] Page Pickup/Siege Engine": 509342414, + "[1F] Guarded By Lasers/Beside 3 Miasma Seekers": 509342415, + "[1F] Near Spikes/Mind the Miasma Seeker": 509342416, + "Birdcage Room/[2F] Bird Room": 509342417, + "[2F] Entryway Upper Walkway/Overlooking Miasma": 509342418, + "[1F] Library/By the Books": 509342419, + "[2F] Library/Behind the Ladder": 509342420, + "[2F] Guarded By Lasers/Before the big reveal...": 509342421, + "Birdcage Room/[2F] Bird Room Secret": 509342422, + "[1F] Library Secret/Pray to the Wallman": 509342423, + "Spike Maze Near Exit/Watch out!": 509342424, + "2nd Laser Room/Can you roll?": 509342425, + "1st Laser Room/Use a bomb?": 509342426, + "Spike Maze Upper Walkway/Just walk right!": 509342427, + "Skulls Chest/Move the Grave": 509342428, + "Spike Maze Near Stairs/In the Corner": 509342429, + "1st Laser Room Obscured/Follow the red laser of death": 509342430, + "Guardhouse 2 - Upper Floor/In the Mound": 509342431, + "Guardhouse 2 - Bottom Floor Secret/Hidden Hallway": 509342432, + "Guardhouse 1 Obscured/Upper Floor Obscured": 509342433, + "Guardhouse 1/Upper Floor": 509342434, + "Guardhouse 1 Ledge HC/Dancing Fox Spirit Holy Cross": 509342435, + "Golden Obelisk Holy Cross/Use the Holy Cross": 509342436, + "Ice Rod Grapple Chest/Freeze the Blob and ascend With Orb": 509342437, + "Above Save Point/Chest": 509342438, + "Above Save Point Obscured/Hidden Path": 509342439, + "Guardhouse 1 Ledge/From Guardhouse 1 Chest": 509342440, + "Near Save Point/Chest": 509342441, + "Ambushed by Spiders/Beneath Spider Chest": 509342442, + "Near Telescope/Up on the Wall": 509342443, + "Ambushed by Spiders/Spider Chest": 509342444, + "Lower Dash Chest/Dash Across": 509342445, + "Lower Grapple Chest/Grapple Across": 509342446, + "Bombable Wall/Follow the Flowers": 509342447, + "Page On Teleporter/Page": 509342448, + "Forest Belltower Save Point/Near Save Point": 509342449, + "Forest Belltower - After Guard Captain/Chest": 509342450, + "East Bell/Forest Belltower - Obscured Near Bell Top Floor": 509342451, + "Forest Belltower Obscured/Obscured Beneath Bell Bottom Floor": 509342452, + "Forest Belltower Page/Page Pickup": 509342453, + "Forest Grave Path - Holy Cross Code by Grave/Single Money Chest": 509342454, + "Forest Grave Path - Above Gate/Chest": 509342455, + "Forest Grave Path - Obscured Chest/Behind the Trees": 509342456, + "Forest Grave Path - Upper Walkway/From the top of the Guardhouse": 509342457, + "The Hero's Sword/Forest Grave Path - Sword Pickup": 509342458, + "The Hero's Sword/Hero's Grave - Tooth Relic": 509342459, + "Fortress Courtyard - From East Belltower/Crack in the Wall": 509342460, + "Fortress Leaf Piles - Secret Chest/Dusty": 509342461, + "Fortress Arena/Hexagon Red": 509342462, + "Fortress Arena/Siege Engine|Vault Key Pickup": 509342463, + "Fortress East Shortcut - Chest Near Slimes/Mind the Custodians": 509342464, + "[West Wing] Candles Holy Cross/Use the Holy Cross": 509342465, + "Westmost Upper Room/[West Wing] Dark Room Chest 1": 509342466, + "Westmost Upper Room/[West Wing] Dark Room Chest 2": 509342467, + "[East Wing] Bombable Wall/Bomb the Wall": 509342468, + "[West Wing] Page Pickup/He will never visit the Far Shore": 509342469, + "Fortress Grave Path - Upper Walkway/Go Around the East Wing": 509342470, + "Vault Hero's Grave/Fortress Grave Path - Chest Right of Grave": 509342471, + "Vault Hero's Grave/Fortress Grave Path - Obscured Chest Left of Grave": 509342472, + "Vault Hero's Grave/Hero's Grave - Flowers Relic": 509342473, + "Bridge/Chest": 509342474, + "Cell Chest 1/Drop the Shortcut Rope": 509342475, + "Obscured Behind Waterfall/Muffling Bell": 509342476, + "Back Room Chest/Lose the Lure or take 2 Damage": 509342477, + "Cell Chest 2/Mind the Custodian": 509342478, + "Near Vault/Already Stolen": 509342479, + "Slorm Room/Tobias was Trapped Here Once...": 509342480, + "Escape Chest/Don't Kick Fimbleton!": 509342481, + "Grapple Above Hot Tub/Look Up": 509342482, + "Above Vault/Obscured Doorway Ledge": 509342483, + "Main Room Top Floor/Mind the Adult Frog": 509342484, + "Main Room Bottom Floor/Altar Chest": 509342485, + "Side Room Secret Passage/Upper Right Corner": 509342486, + "Side Room Chest/Oh No! Our Frogs! They're Dead!": 509342487, + "Side Room Grapple Secret/Grapple on Over": 509342488, + "Magic Orb Pickup/Frult Meeting": 509342489, + "The Librarian/Hexagon Green": 509342490, + "Library Hall/Holy Cross Chest": 509342491, + "Library Lab Chest by Shrine 2/Chest": 509342492, + "Barrels/Back Room Barrel 4": 509350584, } # for setting up the poptracker integration tracker_world = { "map_page_maps": ["maps/maps_pop.json"], - "map_page_locations": ["locations/locations_pop_er.json"], + "map_page_locations": ["locations/locations_pop_er.json", "locations/locations_breakables.json"], "map_page_setting_key": "Slot:{player}:Current Map", "map_page_index": map_page_index, "external_pack_key": "ut_poptracker_path", diff --git a/worlds/tww/Options.py b/worlds/tww/Options.py index ad9c8b3937..b6f2c1511b 100644 --- a/worlds/tww/Options.py +++ b/worlds/tww/Options.py @@ -755,6 +755,54 @@ class TWWOptions(PerGameCommonOptions): remove_music: RemoveMusic death_link: DeathLink + def get_slot_data_dict(self) -> dict[str, Any]: + """ + Returns a dictionary of option name to value to be placed in + the slot data network package. + + :return: Dictionary of option name to value for the slot data. + """ + return self.as_dict( + "progression_dungeons", + "progression_tingle_chests", + "progression_dungeon_secrets", + "progression_puzzle_secret_caves", + "progression_combat_secret_caves", + "progression_savage_labyrinth", + "progression_great_fairies", + "progression_short_sidequests", + "progression_long_sidequests", + "progression_spoils_trading", + "progression_minigames", + "progression_battlesquid", + "progression_free_gifts", + "progression_mail", + "progression_platforms_rafts", + "progression_submarines", + "progression_eye_reef_chests", + "progression_big_octos_gunboats", + "progression_triforce_charts", + "progression_treasure_charts", + "progression_expensive_purchases", + "progression_island_puzzles", + "progression_misc", + "sword_mode", + "required_bosses", + "logic_obscurity", + "logic_precision", + "enable_tuner_logic", + "randomize_dungeon_entrances", + "randomize_secret_cave_entrances", + "randomize_miniboss_entrances", + "randomize_boss_entrances", + "randomize_secret_cave_inner_entrances", + "randomize_fairy_fountain_entrances", + "swift_sail", + "skip_rematch_bosses", + "remove_music", + "death_link", + ) + def get_output_dict(self) -> dict[str, Any]: """ Returns a dictionary of option name to value to be placed in diff --git a/worlds/tww/Presets.py b/worlds/tww/Presets.py index 286494262f..bc1477cb53 100644 --- a/worlds/tww/Presets.py +++ b/worlds/tww/Presets.py @@ -1,61 +1,122 @@ from typing import Any tww_options_presets: dict[str, dict[str, Any]] = { - "Tournament S7": { + "Tournament S8": { "progression_dungeon_secrets": True, "progression_combat_secret_caves": True, "progression_short_sidequests": True, + "progression_long_sidequests": True, "progression_spoils_trading": True, "progression_big_octos_gunboats": True, "progression_mail": True, + "progression_platforms_rafts": True, + "progression_submarines": True, + "progression_big_octos_gunboats": True, + "progression_expensive_purchases": True, "progression_island_puzzles": True, "progression_misc": True, "randomize_mapcompass": "startwith", + "randomize_bigkeys": "startwith", "required_bosses": True, - "num_required_bosses": 3, + "num_required_bosses": 4, + "included_dungeons": ["Forsaken Fortress"], "chest_type_matches_contents": True, "logic_obscurity": "hard", + "randomize_dungeon_entrances": True, "randomize_starting_island": True, "add_shortcut_warps_between_dungeons": True, "start_inventory_from_pool": { "Telescope": 1, "Wind Waker": 1, - "Goddess Tingle Statue": 1, - "Earth Tingle Statue": 1, - "Wind Tingle Statue": 1, "Wind's Requiem": 1, "Ballad of Gales": 1, + "Command Melody": 1, "Earth God's Lyric": 1, "Wind God's Aria": 1, "Song of Passing": 1, - "Progressive Magic Meter": 2, + "Triforce Shard 1": 1, + "Triforce Shard 2": 1, + "Triforce Shard 3": 1, + "Skull Necklace": 20, + "Golden Feather": 20, + "Knight's Crest": 10, + "Green Chu Jelly": 15, + "Nayru's Pearl": 1, + "Din's Pearl": 1, }, - "start_location_hints": ["Ganon's Tower - Maze Chest"], + "start_location_hints": [ + "Windfall Island - Chu Jelly Juice Shop - Give 15 Blue Chu Jelly", + "Ganon's Tower - Maze Chest", + ], "exclude_locations": [ - "Outset Island - Orca - Give 10 Knight's Crests", "Outset Island - Great Fairy", - "Windfall Island - Chu Jelly Juice Shop - Give 15 Green Chu Jelly", + "Windfall Island - Mrs. Marie - Give 1 Joy Pendant", "Windfall Island - Mrs. Marie - Give 21 Joy Pendants", "Windfall Island - Mrs. Marie - Give 40 Joy Pendants", - "Windfall Island - Maggie's Father - Give 20 Skull Necklaces", - "Dragon Roost Island - Rito Aerie - Give Hoskit 20 Golden Feathers", + "Windfall Island - Lenzo's House - Become Lenzo's Assistant", + "Windfall Island - Lenzo's House - Bring Forest Firefly", + "Windfall Island - Sam - Decorate the Town", + "Windfall Island - Kamo - Full Moon Photo", + "Windfall Island - Linda and Anton", + "Dragon Roost Island - Secret Cave", + "Greatfish Isle - Hidden Chest", + "Mother and Child Isles - Inside Mother Isle", + "Fire Mountain - Cave - Chest", + "Fire Mountain - Lookout Platform Chest", + "Fire Mountain - Lookout Platform - Destroy the Cannons", "Fire Mountain - Big Octo", - "Mailbox - Letter from Hoskit's Girlfriend", + "Headstone Island - Top of the Island", + "Headstone Island - Submarine", + "Earth Temple - Behind Curtain Next to Hammer Button", + "The Great Sea - Goron Trading Reward", + "The Great Sea - Withered Trees", "Private Oasis - Big Octo", + "Boating Course - Raft", + "Boating Course - Cave", "Stone Watcher Island - Cave", + "Stone Watcher Island - Lookout Platform Chest", + "Stone Watcher Island - Lookout Platform - Destroy the Cannons", "Overlook Island - Cave", + "Bird's Peak Rock - Cave", + "Pawprint Isle - Wizzrobe Cave", "Thorned Fairy Island - Great Fairy", + "Thorned Fairy Island - Northeastern Lookout Platform - Destroy the Cannons", + "Thorned Fairy Island - Southwestern Lookout Platform - Defeat the Enemies", "Eastern Fairy Island - Great Fairy", + "Eastern Fairy Island - Lookout Platform - Defeat the Cannons and Enemies", "Western Fairy Island - Great Fairy", - "Southern Fairy Island - Great Fairy", - "Northern Fairy Island - Great Fairy", + "Western Fairy Island - Lookout Platform", + "Tingle Island - Ankle - Reward for All Tingle Statues", "Tingle Island - Big Octo", "Diamond Steppe Island - Big Octo", + "Rock Spire Isle - Cave", "Rock Spire Isle - Beedle's Special Shop Ship - 500 Rupee Item", "Rock Spire Isle - Beedle's Special Shop Ship - 950 Rupee Item", "Rock Spire Isle - Beedle's Special Shop Ship - 900 Rupee Item", + "Rock Spire Isle - Western Lookout Platform - Destroy the Cannons", + "Rock Spire Isle - Eastern Lookout Platform - Destroy the Cannons", + "Rock Spire Isle - Center Lookout Platform", + "Rock Spire Isle - Southeast Gunboat", "Shark Island - Cave", + "Horseshoe Island - Northwestern Lookout Platform", + "Horseshoe Island - Southeastern Lookout Platform", + "Flight Control Platform - Submarine", + "Star Island - Cave", + "Star Island - Lookout Platform", + "Star Belt Archipelago - Lookout Platform", + "Five-Star Isles - Lookout Platform - Destroy the Cannons", + "Five-Star Isles - Raft", + "Five-Star Isles - Submarine", + "Seven-Star Isles - Center Lookout Platform", + "Seven-Star Isles - Northern Lookout Platform", + "Seven-Star Isles - Southern Lookout Platform", "Seven-Star Isles - Big Octo", + "Cyclops Reef - Lookout Platform - Defeat the Enemies", + "Two-Eye Reef - Lookout Platform", + "Two-Eye Reef - Big Octo Great Fairy", + "Five-Eye Reef - Lookout Platform", + "Six-Eye Reef - Lookout Platform - Destroy the Cannons", + "Six-Eye Reef - Submarine", ], }, "Miniblins 2025": { diff --git a/worlds/tww/__init__.py b/worlds/tww/__init__.py index 6b6c3ca33a..58e752b5c9 100644 --- a/worlds/tww/__init__.py +++ b/worlds/tww/__init__.py @@ -11,7 +11,7 @@ from BaseClasses import ItemClassification as IC from BaseClasses import MultiWorld, Region, Tutorial from Options import Toggle from worlds.AutoWorld import WebWorld, World -from worlds.Files import APContainer, AutoPatchRegister +from worlds.Files import APPlayerContainer from worlds.generic.Rules import add_item_rule from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, icon_paths, launch_subprocess @@ -51,7 +51,7 @@ components.append( icon_paths["The Wind Waker"] = "ap:worlds.tww/assets/icon.png" -class TWWContainer(APContainer, metaclass=AutoPatchRegister): +class TWWContainer(APPlayerContainer): """ This class defines the container file for The Wind Waker. """ @@ -91,6 +91,14 @@ class TWWWeb(WebWorld): "setup_en.md", "setup/en", ["tanjo3", "Lunix"], + ), + Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Archipelago The Wind Waker software on your computer.", + "Français", + "setup_fr.md", + "setup/fr", + ["mobby45"] ) ] theme = "ocean" @@ -586,7 +594,7 @@ class TWWWorld(World): :return: A dictionary to be sent to the client when it connects to the server. """ - slot_data = self.options.as_dict(*self.options_dataclass.type_hints) + slot_data = self.options.get_slot_data_dict() # Add entrances to `slot_data`. This is the same data that is written to the .aptww file. entrances = { diff --git a/worlds/tww/docs/en_The Wind Waker.md b/worlds/tww/docs/en_The Wind Waker.md index 0158366b3f..77669ce7dd 100644 --- a/worlds/tww/docs/en_The Wind Waker.md +++ b/worlds/tww/docs/en_The Wind Waker.md @@ -76,10 +76,11 @@ at least normal. A few presets are available on the [player options page](../player-options) for your convenience. -- **Tournament S7**: These are (as close to as possible) the settings used in the WWR Racing Server's - [Season 7 Tournament](https://docs.google.com/document/d/1mJj7an-DvpYilwNt-DdlFOy1fz5_NMZaPZvHeIekplc). - The preset features 3 required bosses and hard obscurity difficulty, and while the list of enabled progression options - may seem intimidating, the preset also excludes several locations. +- **Tournament S8**: These are (as close to as possible) the settings used in the WWR Racing Server's + [Season 8 Tournament](https://docs.google.com/document/d/1b8F5DL3P5fgsQC_URiwhpMfqTpsGh2M-KmtTdXVigh4). + The preset features 4 required bosses (with Helmaroc King guaranteed required), dungeon entrance rando, hard obscurity + difficulty, and a variety of overworld checks. While the list of enabled progression options may seem intimidating, + the preset also excludes several locations and starts you with a handful of items. - **Miniblins 2025**: These are (as close to as possible) the settings used in the WWR Racing Server's [2025 Season of Miniblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8). This preset is great if you're new to Wind Waker! There aren't too many locations in the world, and you only need to @@ -115,6 +116,7 @@ This randomizer would not be possible without the help from: - Gamma / SageOfMirrors: (additional programming) - LagoLunatic: (base randomizer, additional assistance) - Lunix: (Linux support, additional programming) +- mobby45: (French Translation of Guides) - Mysteryem: (tracker support, additional programming) - Necrofitz: (additional documentation) - Ouro: (tracker support) diff --git a/worlds/tww/docs/fr_The Wind Waker.md b/worlds/tww/docs/fr_The Wind Waker.md new file mode 100644 index 0000000000..2c89ae53fe --- /dev/null +++ b/worlds/tww/docs/fr_The Wind Waker.md @@ -0,0 +1,142 @@ +# The Wind Waker + +## Où est la page d'options ? + +La [page d'option pour ce jeu](../player-options) contient toutes les options que vous avez besoin de configurer et +exporter afin d'obtenir un fichier de configuration. + +## Que fait la randomisation à ce jeu ? + +Les objets sont mélangés entre les différentes localisations du jeu, donc chaque expérience est unique. +Les localisations randomisés incluent les coffres, les objets reçu des PNJ, ainsi que les trésors submergés sous l'eau. +Le randomiseur inclue également des qualités de vie tel qu'un monde entièrement ouvert, +des cinématiques retirées ainsi qu'une vitesse de navigation améliorée, et plus. + +## Quelles localisations sont mélangés ? + +Seulement les localisations mises en logiques dans les paramètres du monde seront randomisés. +Les localisations restantes dans le jeu auront un rubis jaune. +Celles-ci incluant un message indiquant que la localisation n'est pas randomisé. + +## Quel est l'objectif de The Wind Waker ? + +Atteindre et battre Ganondorf en haut de la tour de Ganon. +Pour cela, vous aurez besoin des huit morceaux de la Triforce du Courage, l'Excalibur entièrerement ranimée (sauf si ce +sont des épées optionnelles ou en mode sans épée), les flèches de lumières, ainsi que tous les objets nécessaires pour +atteindre Ganondorf. + +## A quoi ressemble un objet venant d'un autre monde dans TWW ? + +Les objets appartenant aux autres mondes qui ne sont pas TWW sont représentés +par la Lettre de Père (la lettre que Médolie vous donne pour la donner à Komali), +un objet inutilisé dans le randomiseur. + +## Que se passe-t-il quand un joueur reçoit un objet ? + +Quand le joueur reçoit n'importe quel objet, il sera automatiquement ajouté à l'inventaire de Link. +Link **ne tiendra pas** l'objet au dessus de sa tête comme dans d'autres randomizer de Zelda. + +## J'ai besoin d'aide ! Que dois-je faire ? + +Référez vous à la [FAQ](https://lagolunatic.github.io/wwrando/faq/) premièrement. Ensuite, +essayez les étapes de résolutions de problèmes dans le [guide de mise en place](/tutorial/The%20Wind%20Waker/setup/en). +Si vous êtes encore bloqué, s'il vous plait poser votre question dans le salon textuel Wind Waker +dans le serveur discord d'Archipelago. + +## J'ai ouvert mon jeu dans Dolphin, mais je n'ai aucun de mes items de démarrage ! + +Vous devez vous connecter à la salle du multiworld pour recevoir vos objets. Cela inclut votre inventaire de départ. + +## Problèmes Connus + +- Les rubis randomisés freestanding, butins, et appâts seront aussi données au joueur qui récupère l'objet. + L'objet sera bien envoyé mais le joueur qui le collecte recevra une copie supplémentaire. +- Les objets que tiens Link au dessus de sa tête **ne sont pas** randomisés, + comme les rubis allant des trésors venant des cercles lumineux + jusqu'aux récompenses venant des mini-jeux, ne fonctionneront pas. +- Un objet qui reçoit des messages pour des objets progressifs reçu à des localisations + qui s'envoient plus tôt que prévu seront incorrect. Cela n'affecte pas le gameplay. +- Le compteur de quart de cœur dans les messages lorsqu'on reçoit un objet seront faux d'un. + Cela n'affecte pas le gameplay. +- Il a été signalé que l'itemlink peut être buggé. + Ça ne casse en rien le jeu, mais soyez en conscient. + +N'hésitez pas à signaler n'importe quel autre problème ou suggestion d'amélioration dans le salon textuel Wind Waker +dans le serveur discord d'Archipelago ! + +## Astuces et conseils + +### Où sont les secrets de donjons trouvés à trouver dans les donjons ? + +[Ce document](https://docs.google.com/document/d/1LrjGr6W9970XEA-pzl8OhwnqMqTbQaxCX--M-kdsLos/edit?usp=sharing) +contient des images montrant les différents secrets des donjons. + +### Que font exactement les options obscures et de précisions des options de difficultés ? + +Les options `logic_obscurity` et `logic_precision` modifient la logique du randomizer +pour mettre différentes astuces et techniques en logique. +[Ce document](https://docs.google.com/spreadsheets/d/14ToE1SvNr9yRRqU4GK2qxIsuDUs9Edegik3wUbLtzH8/edit?usp=sharing) +liste parfaitement les changements qui sont fait. Les options sont progressives donc par exemple, +la difficulté obscure dur inclue les astuces normales et durs. +Certains changements ont besoin de la combinaison des deux options. +Par exemple, pour mettre les canons qui détruisent la porte de la Forteresse Maudite pour vous en logique, +les paramètres obscure et précision doivent tout les deux être mis au moins à normal. + +### Quels sont les différents préréglages d'options ? + +Quelques préréglages (presets) sont disponibles sur la [page d'options](../player-options) pour votre confort. + +- **Tournoi Saison 8**: Ce sont (aussi proche que possible) les paramètres utilisés dans le [Tournoi + Saison 8](https://docs.google.com/document/d/1b8F5DL3P5fgsQC_URiwhpMfqTpsGh2M-KmtTdXVigh4) du serveur WWR Racing. + Ce préréglage contient 4 boss requis (avec le Roi Cuirassé garanti d'être requis), + entrée des donjons randomisées, difficulté obscure dur, et une variété de checks dans l'overworld, + même si la liste d'options progressive peut sembler intimidante. + Ce préréglage exclut également plusieurs localisations et vous fait commencez avec plusieurs objets. +- **Miniblins 2025**: Ce sont (aussi proche que possible) les paramètres utilisés dans la + [Saison 2025 de Miniblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8) + du serveur WWR Racing. Ce préréglage est bien si vous êtes nouveau à The Wind Waker ! + Il n'y a pas beaucoup de localisation dans ce monde, et tu as seulement besoin de compléter deux donjons. + Tu commences aussi avec plusieurs objets utiles comme la double magie, + une amélioration de capacité pour votre arc et vos bombes ainsi que six coeurs. +- **Mixed Pools**: Ce sont (aussi proche que possible) les paramètres utilisés dans le + [Tournoi Mixed Pools Co-op](https://docs.google.com/document/d/1YGPTtEgP978TIi0PUAD792OtZbE2jBQpI8XCAy63qpg) + du serveur WWR Racing. + Ce préréglage contient toutes les entrées randomisés et inclue la plupart des localisations + derrière une entrée randomisé. Il y a aussi plusieurs locations de l'overworld, + étant donnée que ces paramètres sont censés être joué dans une équipe de deux joueurs. + Ce préréglage a aussi six boss requis, mais vu que les pools d'entrées sont randomisés, + les boss peuvent être trouvés n'importe où ! Regarder votre carte de l'océan pour + déterminer quels îles les boss sont. + +## Fonctionnalités planifiées + +- Type des coffres Dynamique assorties au contenu en fonction des options activés +- Implémentation des indices venant du randomiseur de base (options de placement des indices et des types d'indices) +- Intégration avec le système d'indice d'Archipelago (ex: indices des enchères) +- Support de l'EnergyLink +- Logique de la voile rapide en tant qu'option +- Continuer la correction de bug + +## Crédits + +Ce randomiseur ne pouvait pas être possible sans l'aide de : + +- BigSharkZ: (Dessinateur de l'îcone) +- Celeste (Maëlle): (correction de logique et de fautes d'orthographe, programmation additionnelle) +- Chavu: (document sur les difficultés de logique) +- CrainWWR: (multiworld et assitance sur la mémoire de Dolphin, programmation additionnelle) +- Cyb3R: (référence pour `TWWClient`) +- DeamonHunter: (programmation additionnelle) +- Dev5ter: (Implémentation initiale de l'AP de TWW) +- Gamma / SageOfMirrors: (programmation additionnelle) +- LagoLunatic: (randomiseur de base, assistance additionelle) +- Lunix: (Support Linux, programmation additionnelle) +- mobby45 (Traduction du guide français) +- Mysteryem: (Support du tracker, programmation additionnelle) +- Necrofitz: (documentation additionelle) +- Ouro: (Support du tracker) +- tal (matzahTalSoup): (guide pour les dungeon secrets) +- Tubamann: (programmation additionnelle) + +Le logo archipelago © 2022 par Krista Corkos et Christopher Wilson, sous licence +[CC BY-NC 4.0](http://creativecommons.org/licenses/by-nc/4.0/). diff --git a/worlds/tww/docs/setup_fr.md b/worlds/tww/docs/setup_fr.md new file mode 100644 index 0000000000..8457c8ef5b --- /dev/null +++ b/worlds/tww/docs/setup_fr.md @@ -0,0 +1,95 @@ +# Guide de mise en place de l'Archipelago de The Wind Waker + +Bienvenue dans l'Archipelago The Wind Waker ! +Ce guide vous aidera à mettre en place le randomiser et à jouer à votre premier multiworld. +Si vous jouez à The Wind Waker, vous devez suivre quelques étapes simple pour commencer. + +## Requis + +Vous aurez besoin des choses suivantes pour être capable de jouer à The Wind Waker: +* L'[émulateur Dolphin](https://dolphin-emu.org/download/). **Nous recommendons d'utiliser la dernière version + sortie.** + * Les utilisateurs Linux peuvent utiliser le paquet flatpak + [disponible sur Flathub](https://flathub.org/apps/org.DolphinEmu.dolphin-emu). +* La dernière version du [Randomiser The Wind Waker pour + Archipelago](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2). + * Veuillez noter que cette version est **différente** de celui utilisé pour le randomiser standard. Cette version + est spécifique à Archipelago. +* Une ISO du jeu Zelda The Wind Waker (version Nord Américaine), probablement nommé "Legend of Zelda, The - The Wind + Waker (USA).iso". + +De manière optionnelle, vous pouvez également télécharger: +* Le [tracker pour Wind Waker](https://github.com/Mysteryem/ww-poptracker/releases/latest) avec + [PopTracker](https://github.com/black-sliver/PopTracker/releases), qui en est la dépendance. +* Des [modèles de personnages personnalisés pour Wind + Waker](https://github.com/Sage-of-Mirrors/Custom-Wind-Waker-Player-Models) afin de personnaliser votre personnage en + jeu. + + +## Mise en place d'un YAML + +Tous les joueurs jouant à The Wind Waker doivent donner un YAML comportant les paramètres de leur monde +à l'hôte de la salle. +Vous pouvez aller sur la [page d'options The Wind Waker](/games/The%20Wind%20Waker/player-options) +pour générer un YAML avec vos options désirés. +Seulement les localisations catégorisées sous les options activés +sous "Progression Locations" seront randomisés dans votre monde. +Une fois que vous êtes heureux avec vos paramètres, +donnez votre fichier YAML à l'hôte de la salle et procéder à la prochaine étape. + +## Connexion à une salle + +L'hôte du multiworld vous donnera un lien pour télécharger votre fichier APTWW +ou un zip contenant les fichiers de tout le monde. +Le fichier APTWW doit être nommé `P#__XXXXX.aptww`, où `#` est l'identifiant du joueur, +`` est votre nom de joueur, et `XXXXX` est l'identifiant de la salle. +L'hôte doit également vous donner le nom de la salle du serveur avec le numéro de port. + +Une fois que vous êtes prêt, suivez ces étapes pour vous connecter à la salle: +1. Lancer le build AP du Randomiser. Si c'est la première fois que vous ouvrez le randomiser, + vous aurez besoin d'indiquer le chemin vers votre ISO de The Wind Waker et le dossier de sortie pour l'ISO randomisé. + Ceux-ci seront sauvegardé pour la prochaine fois que vous ouvrez le programme. +2. Modifier n'importe quel cosmétique comme vous le voulez avec les ajustements désirés + ainsi que la personnalisation de votre personnage desiré. +3. Pour le fichier APTWW, naviguer et localiser le chemin du fichier. +4. Appuyer sur `Randomize` en bas à droite. + Cela va randomiser et mettre l'ISO dans le dossier de sortie que vous avez renseigné. + Le fichier sera nommé `TWW AP_YYYYY_P# ().iso`, où `YYYYY` est le numéro de votre seed, + `#` est l'identifiant de votre joueur, et `` est le nom de votre joueur (nom de slot). + Veuillez vérifier que ces valeurs sont correctes pour votre multiworld. +5. Ouvrez Dolphin et utilisez le pour ouvrir l'iso randomisé. +6. Lancer `ArchipelagoLauncher.exe` (sans le `.exe` sur Linux) et choisissez `The Wind Waker Client`, + Cela va lancer le client texte. +7. Si Dolphin n'est pas encore ouvert, ou que vous n'avez pas encore commencé de nouveau fichier, + vous serez demandé à le faire. + * Une fois que vous avez ouvert votre ISO dans Dolphin, le client doit dire "Dolphin connected successfully.". +8. Connectez-vous à la salle entrant le nom du serveur et son numéro de port en haut et cliquer sur `Connect`. + Pour ceux qui hébergent sur le site web, cela sera `archipelago.gg:`, où `` est le numéro de port. + Si un jeu est hébergé à partir de `ArchipelagoServer.exe` (sans le `.exe` sur Linux), + le numéro de port par défaut est `38281` mais il peut être changé dans le `host.yaml`. +9. Si tu as ouvert ton ISO correspondant au multiworld auquel tu es connecté, + ça doit authentifier ton nom de slot automatiquement quand tu commences une nouveau fichier de sauvegarde. + +## Résolutions de problèmes +* Vérifier que vous utilisez la même version d'Archipelago que celui qui a généré le multiworld. +* Vérifier que `tww.apworld` n'est pas dans votre dossier d'installation Archipelago dans le dossier `custom_worlds`. +* Vérifier que vous utiliser la bonne version du build du randomiser que vous utilisez pour la version d'Archipelago. + * Le build doit donner un message d'erreur vous dirigeant vers la bonne version. + Vous pouvez aussi consulter les notes de version des builds AP de TWW + [ici](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2), + afin de voir avec quelles versions d'Archipelago chaque build est compatible avec. +* Ne pas lancer le Launcher d'Archipelago ou Dolphin en tant qu'Administrateur sur Windows. +* Si vous rencontrez des problèmes avec l'authentification, + vérifier que la ROM randomisé est ouverte dans Dolphin et correspond au multiworld auquel vous vous connectez. +* Vérifier que vous n'utilisez aucune triche Dolphin ou que des codes de triches sont activés. + Certains codes peut interférer de manière imprévue avec l'émulation et + rendre la résolution des problèmes compliquées. +* Vérifier que `Modifier la taille de la mémoire émulée` dans Dolphin + (situé sous `Options` > `Configuration` > `Avancé`) est **désactivé**. +* Si le client ne peut pas se connecter à Dolphin, Vérifier que Dolphin est situé sur le même disque qu'Archipelago. + D'après certaines informations, avoir Dolphin sur un disque dur externe cause des problèmes de connexion. +* Vérifier que la `Région de remplacement` dans Dolphin (situé sous `Options` > `Configuration` > `Général`) + est mise à `NTSC-U`. +* Si vous lancez un menu de démarrage de Gamecube personnalisé, + vous aurez besoin de le passer en allant dans `Options` > `Configuration` > `GameCube` + et cocher `Passer le Menu Principal`. diff --git a/worlds/tww/randomizers/ItemPool.py b/worlds/tww/randomizers/ItemPool.py index 86c02f3932..e3d62fa973 100644 --- a/worlds/tww/randomizers/ItemPool.py +++ b/worlds/tww/randomizers/ItemPool.py @@ -110,6 +110,14 @@ def get_pool_core(world: "TWWWorld") -> tuple[list[str], list[str]]: else: filler_pool.extend([item] * data.quantity) + # If the player starts with a sword, add one to the precollected items list and remove one from the item pool. + if world.options.sword_mode == "start_with_sword": + precollected_items.append("Progressive Sword") + progression_pool.remove("Progressive Sword") + # Or, if it's swordless mode, remove all swords from the item pool. + elif world.options.sword_mode == "swordless": + useful_pool = [item for item in useful_pool if item != "Progressive Sword"] + # Assign useful and filler items to item pools in the world. world.random.shuffle(useful_pool) world.random.shuffle(filler_pool) @@ -117,7 +125,8 @@ def get_pool_core(world: "TWWWorld") -> tuple[list[str], list[str]]: world.filler_pool = filler_pool # Add filler items to place into excluded locations. - pool.extend([world.get_filler_item_name() for _ in world.options.exclude_locations]) + excluded_locations = world.progress_locations.intersection(world.options.exclude_locations) + pool.extend([world.get_filler_item_name() for _ in excluded_locations]) # The remaining of items left to place should be the same as the number of non-excluded locations in the world. nonexcluded_locations = [ @@ -140,17 +149,6 @@ def get_pool_core(world: "TWWWorld") -> tuple[list[str], list[str]]: pool.extend(progression_pool) num_items_left_to_place -= len(progression_pool) - # If the player starts with a sword, add one to the precollected items list and remove one from the item pool. - if world.options.sword_mode == "start_with_sword": - precollected_items.append("Progressive Sword") - num_items_left_to_place += 1 - pool.remove("Progressive Sword") - # Or, if it's swordless mode, remove all swords from the item pool. - elif world.options.sword_mode == "swordless": - while "Progressive Sword" in pool: - num_items_left_to_place += 1 - pool.remove("Progressive Sword") - # Place useful items, then filler items to fill out the remaining locations. pool.extend([world.get_filler_item_name(strict=False) for _ in range(num_items_left_to_place)]) diff --git a/worlds/v6/__init__.py b/worlds/v6/__init__.py index b74f335189..3ed8b7044c 100644 --- a/worlds/v6/__init__.py +++ b/worlds/v6/__init__.py @@ -59,7 +59,7 @@ class V6World(World): self.multiworld.itempool += filltrinkets def generate_basic(self): - musiclist_o = [1,2,3,4,9,12] + musiclist_o = [1,2,3,4,9,11,12] musiclist_s = musiclist_o.copy() if self.options.music_rando: self.multiworld.random.shuffle(musiclist_s) diff --git a/worlds/wargroove/Client.py b/worlds/wargroove/Client.py index 3dc5d6eb0c..0627c7e9f2 100644 --- a/worlds/wargroove/Client.py +++ b/worlds/wargroove/Client.py @@ -496,70 +496,74 @@ class WargrooveContext(CommonContext): async def game_watcher(ctx: WargrooveContext): while not ctx.exit_event.is_set(): - if ctx.syncing == True: - sync_msg = [{'cmd': 'Sync'}] - if ctx.locations_checked: - sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) - await ctx.send_msgs(sync_msg) - ctx.syncing = False - sending = [] - victory = False - for root, dirs, files in os.walk(ctx.game_communication_path): - for file in files: - if file == "deathLinkSend" and ctx.has_death_link: - with open(os.path.join(ctx.game_communication_path, file), 'r') as f: - failed_mission = f.read() - if ctx.slot is not None: - await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}") - os.remove(os.path.join(ctx.game_communication_path, file)) - 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)) - if file == "unitSacrifice" or file == "unitSacrificeAI": - if ctx.has_sacrifice_summon: - stored_units_key = ctx.player_stored_units_key - if file == "unitSacrificeAI": - stored_units_key = ctx.ai_stored_units_key + try: + if ctx.syncing == True: + sync_msg = [{'cmd': 'Sync'}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + ctx.syncing = False + sending = [] + victory = False + for root, dirs, files in os.walk(ctx.game_communication_path): + for file in files: + if file == "deathLinkSend" and ctx.has_death_link: with open(os.path.join(ctx.game_communication_path, file), 'r') as f: - unit_class = f.read() - message = [{"cmd": 'Set', "key": stored_units_key, - "default": [], - "want_reply": True, - "operations": [{"operation": "add", "value": [unit_class[:64]]}]}] - await ctx.send_msgs(message) - os.remove(os.path.join(ctx.game_communication_path, file)) - if file == "unitSummonRequestAI" or file == "unitSummonRequest": - if ctx.has_sacrifice_summon: - stored_units_key = ctx.player_stored_units_key - if file == "unitSummonRequestAI": - stored_units_key = ctx.ai_stored_units_key - with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f: - if stored_units_key in ctx.stored_data: - stored_units = ctx.stored_data[stored_units_key] - if stored_units is None: - stored_units = [] - wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes] - if len(wg1_stored_units) != 0: - summoned_unit = random.choice(wg1_stored_units) - message = [{"cmd": 'Set', "key": stored_units_key, - "default": [], - "want_reply": True, - "operations": [{"operation": "remove", "value": summoned_unit[:64]}]}] - await ctx.send_msgs(message) - f.write(summoned_unit) - os.remove(os.path.join(ctx.game_communication_path, file)) + failed_mission = f.read() + if ctx.slot is not None: + await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}") + os.remove(os.path.join(ctx.game_communication_path, file)) + 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)) + if file == "unitSacrifice" or file == "unitSacrificeAI": + if ctx.has_sacrifice_summon: + stored_units_key = ctx.player_stored_units_key + if file == "unitSacrificeAI": + stored_units_key = ctx.ai_stored_units_key + with open(os.path.join(ctx.game_communication_path, file), 'r') as f: + unit_class = f.read() + message = [{"cmd": 'Set', "key": stored_units_key, + "default": [], + "want_reply": True, + "operations": [{"operation": "add", "value": [unit_class[:64]]}]}] + await ctx.send_msgs(message) + os.remove(os.path.join(ctx.game_communication_path, file)) + if file == "unitSummonRequestAI" or file == "unitSummonRequest": + if ctx.has_sacrifice_summon: + stored_units_key = ctx.player_stored_units_key + if file == "unitSummonRequestAI": + stored_units_key = ctx.ai_stored_units_key + with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f: + if stored_units_key in ctx.stored_data: + stored_units = ctx.stored_data[stored_units_key] + if stored_units is None: + stored_units = [] + wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes] + if len(wg1_stored_units) != 0: + summoned_unit = random.choice(wg1_stored_units) + message = [{"cmd": 'Set', "key": stored_units_key, + "default": [], + "want_reply": True, + "operations": [{"operation": "remove", "value": summoned_unit[:64]}]}] + await ctx.send_msgs(message) + f.write(summoned_unit) + os.remove(os.path.join(ctx.game_communication_path, file)) - ctx.locations_checked = sending - message = [{"cmd": 'LocationChecks', "locations": sending}] - await ctx.send_msgs(message) - if not ctx.finished_game and victory: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - await asyncio.sleep(0.1) + ctx.locations_checked = sending + message = [{"cmd": 'LocationChecks', "locations": sending}] + await ctx.send_msgs(message) + if not ctx.finished_game and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + except Exception as err: + logger.warn("Exception in communication thread, a check may not have been sent: " + str(err)) def print_error_and_close(msg): diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index b2e91c7cf0..bce9bb5151 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -27,14 +27,32 @@ from .rules import set_rules class WitnessWebWorld(WebWorld): theme = "jungle" - tutorials = [Tutorial( + setup_en = Tutorial( "Multiworld Setup Guide", "A guide to playing The Witness with Archipelago.", "English", "setup_en.md", "setup/en", ["NewSoupVi", "Jarno"] - )] + ) + setup_de = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "German", + "setup_de.md", + "setup/de", + ["NewSoupVi"] + ) + setup_fr = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "Français", + "setup_fr.md", + "setup/fr", + ["Rever"] + ) + + tutorials = [setup_en, setup_de, setup_fr] options_presets = witness_option_presets option_groups = witness_option_groups @@ -239,7 +257,7 @@ class WitnessWorld(World): needed_size = 2 needed_size += self.options.puzzle_randomization == "sigma_expert" needed_size += self.options.shuffle_symbols - needed_size += self.options.shuffle_doors > 0 + needed_size += self.options.shuffle_doors != "off" # Then, add checks in order until the required amount of sphere 1 checks is met. diff --git a/worlds/witness/data/static_locations.py b/worlds/witness/data/static_locations.py index a5cfc3b49f..029dcd5dcb 100644 --- a/worlds/witness/data/static_locations.py +++ b/worlds/witness/data/static_locations.py @@ -18,6 +18,7 @@ GENERAL_LOCATIONS = { "Outside Tutorial Outpost Entry Panel", "Outside Tutorial Outpost Exit Panel", + "Glass Factory Entry Panel", "Glass Factory Discard", "Glass Factory Back Wall 5", "Glass Factory Front 3", diff --git a/worlds/witness/docs/setup_de.md b/worlds/witness/docs/setup_de.md new file mode 100644 index 0000000000..82865bb134 --- /dev/null +++ b/worlds/witness/docs/setup_de.md @@ -0,0 +1,46 @@ +# The Witness Randomizer Setup + +## Benötigte Software + +- [The Witness für ein 64-bit-Windows-Betriebssystem (z.B. Steam-Version)](https://store.steampowered.com/app/210970/The_Witness/) +- [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) + +## Optionale Software + +- [ArchipelagoTextClient](https://github.com/ArchipelagoMW/Archipelago/releases) +- [The Witness Auto-Tracker mit Kartenansicht](https://github.com/NewSoupVi/witness_archipelago_tracker/releases), benutzbar mit [PopTracker](https://github.com/black-sliver/PopTracker/releases) + +## Verbindung mit einem Multiworld-Spiel + +1. Öffne The Witness. +2. Erstelle einen neuen Speicherstand. +3. Öffne [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest). +4. Gib die Archipelago-Adresse, deinen Namen und evtl. das Passwort ein. +5. Drücke "Connect". +6. Viel Spaß! + +Wenn du ein vorheriges Spiel fortsetzen willst, ist das auch möüglich: + +1. Öffne The Witness. +2. Lade den Speicherstand für das Multiworld-Spiel, das du weiterspielen willst - Wenn das nicht sowieso schon der ist, den das Spiel automatisch geladen hat. +3. Öffne [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest). +4. Drücke "Load Credentials", um Adresse, Namen und Passwort automatisch zu laden (oder tippe diese manuell ein). +5. Drücke "Connect". + +## Archipelago Text Client + +Es ist empfehlenswert, den "Archipelago Text Client", der eine Textansicht für gesendete und erhaltene Items liefert, beim Spielen nebenbei sichtbar zu haben. +
Diese Nachrichten werden zwar auch im Spiel angezeigt, jedoch nur für ein paar Sekunden. Es ist leicht, eine dieser Nachrichten zu übersehen. + +

Alternativ gibt es den visuellen Auto-Tracker mit Kartenansicht, der im nächsten Kapitel beschrieben wird. + +## Auto-Tracking + +The Witness hat einen voll funktionsfähigen Tracker mit Kartenansicht und Autotracking. + +1. Installiere [PopTracker](https://github.com/black-sliver/PopTracker/releases) und lade den [The Witness Auto-Tracker mit Kartenansicht](https://github.com/NewSoupVi/witness_archipelago_tracker/releases) herunter. +2. Öffne PopTracker, und lade das "The Witness"-Packet. +3. Klicke auf das "AP"-Symbol am oberen Fensterrand. +4. Gib die Archipelago-Adresse, deinen Namen und evtl. das Passwort ein. + +Der Rest sollte vollautomatisch ohne weitere Eingabe funktionieren. Der Tracker wird deine momentanen Items anzeigen und lösbare Rätsel grün auf der Karte anzeigen. Sobald du eine Rätselsequenz abschließt, wird sie grau markiert. \ No newline at end of file diff --git a/worlds/witness/docs/setup_fr.md b/worlds/witness/docs/setup_fr.md new file mode 100644 index 0000000000..db88911b92 --- /dev/null +++ b/worlds/witness/docs/setup_fr.md @@ -0,0 +1,47 @@ +# Guide d'installation du Witness randomizer + +## Logiciels Requis + +- [The Witness pour Windows 64-bit (par exemple, la version Steam)](https://store.steampowered.com/app/210970/The_Witness/) +- [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) + +## Logiciels Facultatifs + +- [ArchipelagoTextClient](https://github.com/ArchipelagoMW/Archipelago/releases) +- [The Witness Map- et Auto-Tracker](https://github.com/NewSoupVi/witness_archipelago_tracker/releases), pour usage avec [PopTracker](https://github.com/black-sliver/PopTracker/releases) + +## Rejoindre un jeu multimonde + +1. Lancez The Witness +2. Commencez une nouvelle partie +3. Lancez [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) +4. Inscrivez l'adresse Archipelago, votre nom de joueur et le mot de passe du jeu multimonde +5. Cliquez sur "Connect" +6. Jouez! + +Pour continuer un jeu multimonde précedemment commencé: + +1. Lancez The Witness +2. Chargez la sauvegarde sur laquelle vous avez dernièrement joué ce monde, si ce n'est pas celle qui a été chargée automatiquement +3. Lancez [The Witness Archipelago Randomizer](https://github.com/NewSoupVi/The-Witness-Randomizer-for-Archipelago/releases/latest) +4. Cliquez sur "Load Credentials" (ou tapez les manuellement) +5. Cliquez sur "Connect" + +## Archipelago Text Client + +Il est recommandé d'utiliser le "Archipelago Text Client" en parallèle afin de suivre quels items vous envoyez et recevez. +
The Witness affiche également ces informations en jeu, mais seulement pour une courte période et donc il est facile de manquer ces messages. + +

Bien sûr, vous pouvez également utiliser l'auto-tracker! + +## Auto-Tracking + +The Witness a un tracker fonctionnel qui supporte l'auto-tracking. + +1. Téléchargez [The Witness Map- and Auto-Tracker](https://github.com/NewSoupVi/witness_archipelago_tracker/releases) et [PopTracker](https://github.com/black-sliver/PopTracker/releases). +2. Ouvrez Poptracker, puis chargez le pack Witness. +3. Cliquez sur l'icone "AP" qui se situe au dessus de la carte. +4. Inscrivez l'adresse Archipelago, votre nom de joueur et le mot de passe du jeu multimonde. + +Le reste devrait être pris en charge par Poptracker - les items que vous recevrez et les puzzles que vous résolverez seront automatiquement indiqués. De plus, Poptracker est en mesure de détecter +vos paramètres de jeu - les puzzles accessibles seront alors masqués ou affichés en fonction de vos paramètres de randomization et de logique. Veuillez noter que le tracker peut être obsolète. \ No newline at end of file diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index c82024cc12..ac5572257f 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -129,7 +129,7 @@ def get_priority_hint_items(world: "WitnessWorld") -> List[str]: "Shadows Laser", ] - if world.options.shuffle_doors >= 2: + if world.options.shuffle_doors >= "doors": priority.add("Desert Laser") priority.update(world.random.sample(lasers, 5)) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 52bddde17e..aed6d3da66 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -435,7 +435,7 @@ class WitnessPlayerLogic: postgame_adjustments = [] # Make some quick references to some options - remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no region accessibility implications. + remote_doors = world.options.shuffle_doors >= "doors" # "Panels" mode has no region accessibility implications. early_caves = world.options.early_caves victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers @@ -592,7 +592,7 @@ class WitnessPlayerLogic: # Make condensed references to some options - remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region access implications. + remote_doors = world.options.shuffle_doors >= "doors" # "Panels" mode has no region access implications. lasers = world.options.shuffle_lasers victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers diff --git a/worlds/witness/test/__init__.py b/worlds/witness/test/__init__.py index c3b427851a..e69de29bb2 100644 --- a/worlds/witness/test/__init__.py +++ b/worlds/witness/test/__init__.py @@ -1,196 +0,0 @@ -from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union - -from BaseClasses import CollectionState, Entrance, Item, Location, Region - -from test.bases import WorldTestBase -from test.general import gen_steps, setup_multiworld -from test.multiworld.test_multiworlds import MultiworldTestBase - -from .. import WitnessWorld -from ..data.utils import cast_not_none - - -class WitnessTestBase(WorldTestBase): - game = "The Witness" - player: ClassVar[int] = 1 - - world: WitnessWorld - - def can_beat_game_with_items(self, items: Iterable[Item]) -> bool: - """ - Check that the items listed are enough to beat the game. - """ - - state = CollectionState(self.multiworld) - for item in items: - state.collect(item) - return state.multiworld.can_beat_game(state) - - def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None: - """ - WorldTestBase.assertAccessDependency, but modified & simplified to work with event items - """ - event_items = [item for item in self.multiworld.get_items() if item.name == item_name] - self.assertTrue(event_items, f"Event item {item_name} does not exist.") - - event_locations = [cast_not_none(event_item.location) for event_item in event_items] - - # Checking for an access dependency on an event item requires a bit of extra work, - # as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it. - # So, we temporarily set the access rules of the event locations to be impossible. - original_rules = {event_location.name: event_location.access_rule for event_location in event_locations} - for event_location in event_locations: - event_location.access_rule = lambda _: False - - # We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30) - test_state = self.multiworld.get_all_state(False) - - self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}") - - test_state.collect(event_items[0]) - - self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}") - - # Restore original access rules. - for event_location in event_locations: - event_location.access_rule = original_rules[event_location.name] - - def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, also make sure that this (non-event) location COULD exist. - """ - - if strict_check: - self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") - - try: - self.world.get_location(location_name) - except KeyError: - self.fail(f"Location {location_name} does not exist.") - - def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, be explicit about whether the location could exist in the first place. - """ - - if strict_check: - self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") - - self.assertRaises( - KeyError, - lambda _: self.world.get_location(location_name), - f"Location {location_name} exists, but is not supposed to.", - ) - - def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None: - """ - Assert that the specified mapping of items is enough to beat the game, - and that having one less of any item would result in the game being unbeatable. - """ - # Find the actual items - found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts] - actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts} - for item in found_items: - if len(actual_items[item.name]) < required_item_counts[item.name]: - actual_items[item.name].append(item) - - # Assert that enough items exist in the item pool to satisfy the specified required counts - for item_name, item_objects in actual_items.items(): - self.assertEqual( - len(item_objects), - required_item_counts[item_name], - f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, " - f"only found {len(item_objects)}", - ) - - # assert that multiworld is beatable with the items specified - self.assertTrue( - self.can_beat_game_with_items(item for items in actual_items.values() for item in items), - f"Could not beat game with items: {required_item_counts}", - ) - - # assert that one less copy of any item would result in the multiworld being unbeatable - for item_name, item_objects in actual_items.items(): - with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"): - removed_item = item_objects.pop() - self.assertFalse( - self.can_beat_game_with_items(item for items in actual_items.values() for item in items), - f"Game was beatable despite having {len(item_objects)} copies of {item_name} " - f"instead of the specified {required_item_counts[item_name]}", - ) - item_objects.append(removed_item) - - -class WitnessMultiworldTestBase(MultiworldTestBase): - options_per_world: List[Dict[str, Any]] - common_options: Dict[str, Any] = {} - - def setUp(self) -> None: - """ - Set up a multiworld with multiple players, each using different options. - """ - - self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ()) - - for world, options in zip(self.multiworld.worlds.values(), self.options_per_world): - for option_name, option_value in {**self.common_options, **options}.items(): - option = getattr(world.options, option_name) - self.assertIsNotNone(option) - - option.value = option.from_any(option_value).value - - self.assertSteps(gen_steps) - - def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: - """ - Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool. - """ - - items = self.get_items_by_name(item_names, player) - for item in items: - self.multiworld.state.collect(item) - return items - - def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: - """ - Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool. - """ - - if isinstance(item_names, str): - item_names = (item_names,) - return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player] - - def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, also make sure that this (non-event) location COULD exist. - """ - - world = self.multiworld.worlds[player] - - if strict_check: - self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") - - try: - world.get_location(location_name) - except KeyError: - self.fail(f"Location {location_name} does not exist.") - - def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, be explicit about whether the location could exist in the first place. - """ - - world = self.multiworld.worlds[player] - - if strict_check: - self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") - - self.assertRaises( - KeyError, - lambda _: world.get_location(location_name), - f"Location {location_name} exists, but is not supposed to.", - ) diff --git a/worlds/witness/test/bases.py b/worlds/witness/test/bases.py new file mode 100644 index 0000000000..c3b427851a --- /dev/null +++ b/worlds/witness/test/bases.py @@ -0,0 +1,196 @@ +from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union + +from BaseClasses import CollectionState, Entrance, Item, Location, Region + +from test.bases import WorldTestBase +from test.general import gen_steps, setup_multiworld +from test.multiworld.test_multiworlds import MultiworldTestBase + +from .. import WitnessWorld +from ..data.utils import cast_not_none + + +class WitnessTestBase(WorldTestBase): + game = "The Witness" + player: ClassVar[int] = 1 + + world: WitnessWorld + + def can_beat_game_with_items(self, items: Iterable[Item]) -> bool: + """ + Check that the items listed are enough to beat the game. + """ + + state = CollectionState(self.multiworld) + for item in items: + state.collect(item) + return state.multiworld.can_beat_game(state) + + def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None: + """ + WorldTestBase.assertAccessDependency, but modified & simplified to work with event items + """ + event_items = [item for item in self.multiworld.get_items() if item.name == item_name] + self.assertTrue(event_items, f"Event item {item_name} does not exist.") + + event_locations = [cast_not_none(event_item.location) for event_item in event_items] + + # Checking for an access dependency on an event item requires a bit of extra work, + # as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it. + # So, we temporarily set the access rules of the event locations to be impossible. + original_rules = {event_location.name: event_location.access_rule for event_location in event_locations} + for event_location in event_locations: + event_location.access_rule = lambda _: False + + # We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30) + test_state = self.multiworld.get_all_state(False) + + self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}") + + test_state.collect(event_items[0]) + + self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}") + + # Restore original access rules. + for event_location in event_locations: + event_location.access_rule = original_rules[event_location.name] + + def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, also make sure that this (non-event) location COULD exist. + """ + + if strict_check: + self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") + + try: + self.world.get_location(location_name) + except KeyError: + self.fail(f"Location {location_name} does not exist.") + + def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, be explicit about whether the location could exist in the first place. + """ + + if strict_check: + self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") + + self.assertRaises( + KeyError, + lambda _: self.world.get_location(location_name), + f"Location {location_name} exists, but is not supposed to.", + ) + + def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None: + """ + Assert that the specified mapping of items is enough to beat the game, + and that having one less of any item would result in the game being unbeatable. + """ + # Find the actual items + found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts] + actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts} + for item in found_items: + if len(actual_items[item.name]) < required_item_counts[item.name]: + actual_items[item.name].append(item) + + # Assert that enough items exist in the item pool to satisfy the specified required counts + for item_name, item_objects in actual_items.items(): + self.assertEqual( + len(item_objects), + required_item_counts[item_name], + f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, " + f"only found {len(item_objects)}", + ) + + # assert that multiworld is beatable with the items specified + self.assertTrue( + self.can_beat_game_with_items(item for items in actual_items.values() for item in items), + f"Could not beat game with items: {required_item_counts}", + ) + + # assert that one less copy of any item would result in the multiworld being unbeatable + for item_name, item_objects in actual_items.items(): + with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"): + removed_item = item_objects.pop() + self.assertFalse( + self.can_beat_game_with_items(item for items in actual_items.values() for item in items), + f"Game was beatable despite having {len(item_objects)} copies of {item_name} " + f"instead of the specified {required_item_counts[item_name]}", + ) + item_objects.append(removed_item) + + +class WitnessMultiworldTestBase(MultiworldTestBase): + options_per_world: List[Dict[str, Any]] + common_options: Dict[str, Any] = {} + + def setUp(self) -> None: + """ + Set up a multiworld with multiple players, each using different options. + """ + + self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ()) + + for world, options in zip(self.multiworld.worlds.values(), self.options_per_world): + for option_name, option_value in {**self.common_options, **options}.items(): + option = getattr(world.options, option_name) + self.assertIsNotNone(option) + + option.value = option.from_any(option_value).value + + self.assertSteps(gen_steps) + + def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: + """ + Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool. + """ + + items = self.get_items_by_name(item_names, player) + for item in items: + self.multiworld.state.collect(item) + return items + + def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: + """ + Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool. + """ + + if isinstance(item_names, str): + item_names = (item_names,) + return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player] + + def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, also make sure that this (non-event) location COULD exist. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + try: + world.get_location(location_name) + except KeyError: + self.fail(f"Location {location_name} does not exist.") + + def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, be explicit about whether the location could exist in the first place. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + self.assertRaises( + KeyError, + lambda _: world.get_location(location_name), + f"Location {location_name} exists, but is not supposed to.", + ) diff --git a/worlds/witness/test/test_auto_elevators.py b/worlds/witness/test/test_auto_elevators.py index f91943e855..6762657b8e 100644 --- a/worlds/witness/test/test_auto_elevators.py +++ b/worlds/witness/test/test_auto_elevators.py @@ -1,4 +1,4 @@ -from ..test import WitnessMultiworldTestBase +from ..test.bases import WitnessMultiworldTestBase class TestElevatorsComeToYouBleed(WitnessMultiworldTestBase): diff --git a/worlds/witness/test/test_disable_non_randomized.py b/worlds/witness/test/test_disable_non_randomized.py index bf285f035d..00071ec5f6 100644 --- a/worlds/witness/test/test_disable_non_randomized.py +++ b/worlds/witness/test/test_disable_non_randomized.py @@ -1,5 +1,5 @@ from ..rules import _has_lasers -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestDisableNonRandomized(WitnessTestBase): diff --git a/worlds/witness/test/test_door_shuffle.py b/worlds/witness/test/test_door_shuffle.py index ca4d6e0aa8..be0a3332f3 100644 --- a/worlds/witness/test/test_door_shuffle.py +++ b/worlds/witness/test/test_door_shuffle.py @@ -1,7 +1,7 @@ from typing import cast from .. import WitnessWorld -from ..test import WitnessMultiworldTestBase, WitnessTestBase +from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestIndividualDoors(WitnessTestBase): diff --git a/worlds/witness/test/test_easter_egg_shuffle.py b/worlds/witness/test/test_easter_egg_shuffle.py index 300d32f97f..a95357c6e1 100644 --- a/worlds/witness/test/test_easter_egg_shuffle.py +++ b/worlds/witness/test/test_easter_egg_shuffle.py @@ -3,7 +3,7 @@ from typing import cast from BaseClasses import LocationProgressType from .. import WitnessWorld -from ..test import WitnessMultiworldTestBase +from ..test.bases import WitnessMultiworldTestBase class TestEasterEggShuffle(WitnessMultiworldTestBase): diff --git a/worlds/witness/test/test_ep_shuffle.py b/worlds/witness/test/test_ep_shuffle.py index 3423909166..17297fbcf3 100644 --- a/worlds/witness/test/test_ep_shuffle.py +++ b/worlds/witness/test/test_ep_shuffle.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestIndividualEPs(WitnessTestBase): diff --git a/worlds/witness/test/test_lasers.py b/worlds/witness/test/test_lasers.py index 5e60dfc521..5681757161 100644 --- a/worlds/witness/test/test_lasers.py +++ b/worlds/witness/test/test_lasers.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestSymbolsRequiredToWinElevatorNormal(WitnessTestBase): diff --git a/worlds/witness/test/test_panel_hunt.py b/worlds/witness/test/test_panel_hunt.py index 2f8434802b..6dea655070 100644 --- a/worlds/witness/test/test_panel_hunt.py +++ b/worlds/witness/test/test_panel_hunt.py @@ -1,6 +1,6 @@ from BaseClasses import CollectionState -from worlds.witness.test import WitnessMultiworldTestBase, WitnessTestBase +from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestMaxPanelHuntMinChecks(WitnessTestBase): diff --git a/worlds/witness/test/test_roll_other_options.py b/worlds/witness/test/test_roll_other_options.py index 05f3235a1f..72313034e4 100644 --- a/worlds/witness/test/test_roll_other_options.py +++ b/worlds/witness/test/test_roll_other_options.py @@ -1,5 +1,5 @@ from ..options import ElevatorsComeToYou -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase # These are just some random options combinations, just to catch whether I broke anything obvious diff --git a/worlds/witness/test/test_symbol_shuffle.py b/worlds/witness/test/test_symbol_shuffle.py index 3be874f3c0..fb1d820815 100644 --- a/worlds/witness/test/test_symbol_shuffle.py +++ b/worlds/witness/test/test_symbol_shuffle.py @@ -1,4 +1,4 @@ -from ..test import WitnessMultiworldTestBase, WitnessTestBase +from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestSymbols(WitnessTestBase): diff --git a/worlds/witness/test/test_weird_traversals.py b/worlds/witness/test/test_weird_traversals.py index 47b69b01fb..9447a13922 100644 --- a/worlds/witness/test/test_weird_traversals.py +++ b/worlds/witness/test/test_weird_traversals.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestWeirdTraversalRequirements(WitnessTestBase):