diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07ae1136fc..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' @@ -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: | diff --git a/BaseClasses.py b/BaseClasses.py index ba07868655..10d0540633 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -7,11 +7,11 @@ 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 @@ -585,26 +585,9 @@ class MultiWorld(): if self.has_beaten_game(state): return True - base_locations = self.get_locations() if locations is None else locations - prog_locations = {location for location in base_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 @@ -889,20 +872,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: diff --git a/CommonClient.py b/CommonClient.py index 454150acbf..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""" @@ -379,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 @@ -638,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: @@ -938,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", @@ -1018,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 index c6d22a4fb8..46393aab9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,7 @@ COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI # No release for arm architecture. Skip. RUN if [ "$TARGETARCH" = "amd64" ]; then \ - cp /tmp/EnemizerCLI EnemizerCLI; \ + cp -r /tmp/EnemizerCLI EnemizerCLI; \ fi; \ rm -rf /tmp/EnemizerCLI @@ -94,5 +94,7 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ 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/Main.py b/Main.py index 456820a461..67c861c0f4 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 @@ -14,7 +15,7 @@ from Fill import FillError, balance_multiworld_progression, distribute_items_res 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 @@ -93,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") @@ -100,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: @@ -126,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) @@ -239,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] @@ -258,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() @@ -315,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()}, @@ -325,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, @@ -333,12 +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) for key in ("slot_data", "er_hint_data"): multidata[key] = convert_to_base_types(multidata[key]) - multidata = zlib.compress(pickle.dumps(multidata), 9) + 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/MultiServer.py b/MultiServer.py index 108795d84f..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,7 +1948,7 @@ 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}]) @@ -1990,7 +1992,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): 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) + ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True) ctx.save() elif cmd == 'UpdateHint': diff --git a/NetUtils.py b/NetUtils.py index cc6e917c88..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): @@ -471,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/Options.py b/Options.py index e87280ca14..c948e7e95f 100644 --- a/Options.py +++ b/Options.py @@ -1644,7 +1644,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/Utils.py b/Utils.py index 9c1171096e..abf359f43e 100644 --- a/Utils.py +++ b/Utils.py @@ -483,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. 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/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/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/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/upload.py b/WebHostLib/upload.py index ee4ba6a53e..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 diff --git a/docs/deploy using containers.md b/docs/deploy using containers.md index bb77900174..6db38d443f 100644 --- a/docs/deploy using containers.md +++ b/docs/deploy using containers.md @@ -9,9 +9,10 @@ Follow these steps to build and deploy a containerized instance of the web host What you'll need: * A container runtime engine such as: - * [Docker](https://www.docker.com/) - * [Podman](https://podman.io/) + * [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 .` diff --git a/docs/network protocol.md b/docs/network protocol.md index 4b66b7b1d3..b40cf31b85 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -340,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 | diff --git a/docs/world api.md b/docs/world api.md index 677b1636e6..17cf81fe92 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -515,6 +515,7 @@ 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. @@ -538,7 +539,7 @@ In addition, the following methods can be implemented and are called in this ord 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 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/setup.py b/setup.py index cd1b1e8710..704325d70c 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,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.0.0' try: @@ -89,7 +93,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"] 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_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/worlds/AutoWorld.py b/worlds/AutoWorld.py index 6c1683e3d5..568bdcf9a4 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") @@ -450,7 +450,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 diff --git a/worlds/Files.py b/worlds/Files.py index fa3739a5a9..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): diff --git a/worlds/__init__.py b/worlds/__init__.py index 7db651bdd9..80240275b0 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 diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index c9b1076644..250e4a8826 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -4,6 +4,7 @@ checking or launching the client, otherwise it will probably cause circular impo """ import asyncio +import copy import enum import subprocess from typing import Any @@ -13,7 +14,7 @@ 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 +28,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 +132,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" diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index 2d11d537fb..b79170dac2 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -463,12 +463,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 +545,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 ( 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/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/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 b1b033e01d..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: @@ -354,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 @@ -367,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 @@ -421,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 @@ -458,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 @@ -499,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/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py index bcb4ab8e2a..32849eec9b 100644 --- a/worlds/musedash/MuseDashData.py +++ b/worlds/musedash/MuseDashData.py @@ -654,4 +654,11 @@ SONG_DATA: Dict[str, SongData] = { "#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/tww/__init__.py b/worlds/tww/__init__.py index 5432d200ae..58e752b5c9 100644 --- a/worlds/tww/__init__.py +++ b/worlds/tww/__init__.py @@ -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" diff --git a/worlds/tww/docs/en_The Wind Waker.md b/worlds/tww/docs/en_The Wind Waker.md index a49c8d6697..77669ce7dd 100644 --- a/worlds/tww/docs/en_The Wind Waker.md +++ b/worlds/tww/docs/en_The Wind Waker.md @@ -116,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/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)