diff --git a/Launcher.py b/Launcher.py index 88e2070e9c..82326aacd7 100644 --- a/Launcher.py +++ b/Launcher.py @@ -11,6 +11,7 @@ Additional components can be added to worlds.LauncherComponents.components. import argparse import logging import multiprocessing +import os import shlex import subprocess import sys @@ -41,13 +42,17 @@ def open_host_yaml(): if is_linux: exe = which('sensible-editor') or which('gedit') or \ which('xdg-open') or which('gnome-open') or which('kde-open') - subprocess.Popen([exe, file]) elif is_macos: exe = which("open") - subprocess.Popen([exe, file]) else: webbrowser.open(file) + return + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + subprocess.Popen([exe, file], env=env) def open_patch(): suffixes = [] @@ -92,7 +97,11 @@ def open_folder(folder_path): return if exe: - subprocess.Popen([exe, folder_path]) + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + subprocess.Popen([exe, folder_path], env=env) else: logging.warning(f"No file browser available to open {folder_path}") diff --git a/Options.py b/Options.py index 3d08c5f003..26e145926e 100644 --- a/Options.py +++ b/Options.py @@ -1524,9 +1524,11 @@ class PlandoItems(Option[typing.List[PlandoItem]]): f"dictionary, not {type(items)}") locations = item.get("locations", []) if not locations: - locations = item.get("location", ["Everywhere"]) + locations = item.get("location", []) if locations: count = 1 + else: + locations = ["Everywhere"] if isinstance(locations, str): locations = [locations] if not isinstance(locations, list): diff --git a/Utils.py b/Utils.py index b38809ba1b..f203890550 100644 --- a/Utils.py +++ b/Utils.py @@ -226,7 +226,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: from shutil import which open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) assert open_command, "Didn't find program for open_file! Please report this together with system details." - subprocess.call([open_command, filename]) + + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + subprocess.call([open_command, filename], env=env) # from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes @@ -708,25 +713,30 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: res.put(open_filename(*args)) +def _run_for_stdout(*args: str): + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None + + def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: logging.info(f"Opening file input dialog for {title}.") - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_linux: # prefer native dialog from shutil import which kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) + return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) selection = (f"--filename={suggest}",) if suggest else () - return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -760,21 +770,18 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_linux: # prefer native dialog from shutil import which kdialog = which("kdialog") if kdialog: - return run(kdialog, f"--title={title}", "--getexistingdirectory", + return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory", os.path.abspath(suggest) if suggest else ".") zenity = which("zenity") if zenity: z_filters = ("--directory",) selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () - return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -801,9 +808,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def messagebox(title: str, text: str, error: bool = False) -> None: - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_kivy_running(): from kvui import MessageBox MessageBox(title, text, error).open() @@ -814,10 +818,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None: from shutil import which kdialog = which("kdialog") if kdialog: - return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) + return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) zenity = which("zenity") if zenity: - return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") + return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") elif is_windows: import ctypes diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 45b26b175e..66b6f5560b 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -119,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s # AP Container elif handler: data = zfile.open(file, "r").read() - patch = handler(BytesIO(data)) - patch.read() - files[patch.player] = data + with zipfile.ZipFile(BytesIO(data)) as container: + player = json.loads(container.open("archipelago.json").read())["player"] + files[player] = data # Spoiler elif file.filename.endswith(".txt"): diff --git a/worlds/Files.py b/worlds/Files.py index 447219bd19..fa3739a5a9 100644 --- a/worlds/Files.py +++ b/worlds/Files.py @@ -158,6 +158,7 @@ class APContainer: class APPlayerContainer(APContainer): """A zipfile containing at least archipelago.json meant for a player""" game: ClassVar[Optional[str]] = None + patch_file_ending: str = "" player: Optional[int] player_name: str @@ -184,6 +185,7 @@ class APPlayerContainer(APContainer): "player": self.player, "player_name": self.player_name, "game": self.game, + "patch_file_ending": self.patch_file_ending, }) return manifest @@ -223,7 +225,6 @@ class APProcedurePatch(APAutoPatchInterface): """ hash: Optional[str] # base checksum of source file source_data: bytes - patch_file_ending: str = "" files: Dict[str, bytes] @classmethod @@ -245,7 +246,6 @@ class APProcedurePatch(APAutoPatchInterface): manifest = super(APProcedurePatch, self).get_manifest() manifest["base_checksum"] = self.hash manifest["result_file_ending"] = self.result_file_ending - manifest["patch_file_ending"] = self.patch_file_ending manifest["procedure"] = self.procedure if self.procedure == APDeltaPatch.procedure: manifest["compatible_version"] = 5 diff --git a/worlds/civ_6/Container.py b/worlds/civ_6/Container.py index 0c5340d9c2..a5790c1ec4 100644 --- a/worlds/civ_6/Container.py +++ b/worlds/civ_6/Container.py @@ -1,10 +1,9 @@ from dataclasses import dataclass import os -import io from typing import TYPE_CHECKING, Dict, List, Optional, cast import zipfile from BaseClasses import Location -from worlds.Files import APContainer, AutoPatchRegister +from worlds.Files import APPlayerContainer from .Enum import CivVICheckType from .Locations import CivVILocation, CivVILocationData @@ -26,22 +25,19 @@ class CivTreeItem: ui_tree_row: int -class CivVIContainer(APContainer, metaclass=AutoPatchRegister): +class CivVIContainer(APPlayerContainer): """ Responsible for generating the dynamic mod files for the Civ VI multiworld """ game: Optional[str] = "Civilization VI" patch_file_ending = ".apcivvi" - def __init__(self, patch_data: Dict[str, str] | io.BytesIO, base_path: str = "", output_directory: str = "", + def __init__(self, patch_data: Dict[str, str], base_path: str = "", output_directory: str = "", player: Optional[int] = None, player_name: str = "", server: str = ""): - if isinstance(patch_data, io.BytesIO): - super().__init__(patch_data, player, player_name, server) - else: - self.patch_data = patch_data - self.file_path = base_path - container_path = os.path.join(output_directory, base_path + ".apcivvi") - super().__init__(container_path, player, player_name, server) + self.patch_data = patch_data + self.file_path = base_path + container_path = os.path.join(output_directory, base_path + ".apcivvi") + super().__init__(container_path, player, player_name, server) def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None: for filename, yml in self.patch_data.items(): diff --git a/worlds/dark_souls_3/Locations.py b/worlds/dark_souls_3/Locations.py index c84d91e516..b4e45fb577 100644 --- a/worlds/dark_souls_3/Locations.py +++ b/worlds/dark_souls_3/Locations.py @@ -884,7 +884,7 @@ location_tables: Dict[str, List[DS3LocationData]] = { DS3LocationData("RS: Homeward Bone - balcony by Farron Keep", "Homeward Bone x2"), DS3LocationData("RS: Titanite Shard - woods, surrounded by enemies", "Titanite Shard"), DS3LocationData("RS: Twin Dragon Greatshield - woods by Crucifixion Woods bonfire", - "Twin Dragon Greatshield"), + "Twin Dragon Greatshield", missable=True), # After Eclipse DS3LocationData("RS: Sorcerer Hood - water beneath stronghold", "Sorcerer Hood", hidden=True), # Hidden fall DS3LocationData("RS: Sorcerer Robe - water beneath stronghold", "Sorcerer Robe", @@ -1887,7 +1887,7 @@ location_tables: Dict[str, List[DS3LocationData]] = { DS3LocationData("AL: Twinkling Titanite - lizard after light cathedral #2", "Twinkling Titanite", lizard=True), DS3LocationData("AL: Aldrich's Ruby - dark cathedral, miniboss", "Aldrich's Ruby", - miniboss=True), # Deep Accursed drop + miniboss=True, missable=True), # Deep Accursed drop, missable after defeating Aldrich DS3LocationData("AL: Aldrich Faithful - water reserves, talk to McDonnel", "Aldrich Faithful", hidden=True), # Behind illusory wall diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 5e1003d2a9..94150faf05 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -705,7 +705,7 @@ class DarkSouls3World(World): if self._is_location_available("US: Young White Branch - by white tree #2"): self._add_item_rule( "US: Young White Branch - by white tree #2", - lambda item: item.player == self.player and not item.data.unique + lambda item: item.player != self.player or not item.data.unique ) # Make sure the Storm Ruler is available BEFORE Yhorm the Giant diff --git a/worlds/dkc3/Regions.py b/worlds/dkc3/Regions.py index 6e968dbe1e..c6c7dd362e 100644 --- a/worlds/dkc3/Regions.py +++ b/worlds/dkc3/Regions.py @@ -802,8 +802,10 @@ def connect_regions(world: World, level_list): for i in range(0, len(kremwood_forest_levels) - 1): connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[i]) - connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1], - lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player))) + connection = connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1], + lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player))) + world.multiworld.register_indirect_condition(world.get_location(LocationName.riverside_race_flag).parent_region, + connection) # Cotton-Top Cove Connections cotton_top_cove_levels = [ @@ -837,8 +839,11 @@ def connect_regions(world: World, level_list): connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region, lambda state: (state.has(ItemName.bowling_ball, world.player, 1))) else: - connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region, - lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player))) + connection = connect(world, world.player, names, LocationName.mekanos_region, + LocationName.sky_high_secret_region, + lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player))) + world.multiworld.register_indirect_condition(world.get_location(LocationName.bleaks_house).parent_region, + connection) # K3 Connections k3_levels = [ @@ -946,3 +951,4 @@ def connect(world: World, player: int, used_names: typing.Dict[str, int], source source_region.exits.append(connection) connection.connect(target_region) + return connection diff --git a/worlds/dlcquest/Rules.py b/worlds/dlcquest/Rules.py index 3461d0633e..5dfd80165a 100644 --- a/worlds/dlcquest/Rules.py +++ b/worlds/dlcquest/Rules.py @@ -280,16 +280,19 @@ def set_boss_door_requirements_rules(player, world): set_rule(world.get_entrance("Boss Door", player), has_3_swords) -def set_lfod_self_obtained_items_rules(world_options, player, world): +def set_lfod_self_obtained_items_rules(world_options, player, multiworld): if world_options.item_shuffle != Options.ItemShuffle.option_disabled: return - set_rule(world.get_entrance("Vines", player), + world = multiworld.worlds[player] + set_rule(world.get_entrance("Vines"), lambda state: state.has("Incredibly Important Pack", player)) - set_rule(world.get_entrance("Behind Rocks", player), + set_rule(world.get_entrance("Behind Rocks"), lambda state: state.can_reach("Cut Content", 'region', player)) - set_rule(world.get_entrance("Pickaxe Hard Cave", player), + multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Behind Rocks")) + set_rule(world.get_entrance("Pickaxe Hard Cave"), lambda state: state.can_reach("Cut Content", 'region', player) and state.has("Name Change Pack", player)) + multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Pickaxe Hard Cave")) def set_lfod_shuffled_items_rules(world_options, player, world): diff --git a/worlds/factorio/Client.py b/worlds/factorio/Client.py index 199cb29b86..d7992c3276 100644 --- a/worlds/factorio/Client.py +++ b/worlds/factorio/Client.py @@ -69,7 +69,9 @@ class FactorioContext(CommonContext): # updated by spinup server mod_version: Version = Version(0, 0, 0) - def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool): + def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool, + rcon_port: int, rcon_password: str, server_settings_path: str | None, + factorio_server_args: tuple[str, ...]): super(FactorioContext, self).__init__(server_address, password) self.send_index: int = 0 self.rcon_client = None @@ -82,6 +84,10 @@ class FactorioContext(CommonContext): self.filter_item_sends: bool = filter_item_sends self.multiplayer: bool = False # whether multiple different players have connected self.bridge_chat_out: bool = bridge_chat_out + self.rcon_port: int = rcon_port + self.rcon_password: str = rcon_password + self.server_settings_path: str = server_settings_path + self.additional_factorio_server_args = factorio_server_args @property def energylink_key(self) -> str: @@ -126,6 +132,18 @@ class FactorioContext(CommonContext): self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " f"{text}") + @property + def server_args(self) -> tuple[str, ...]: + if self.server_settings_path: + return ( + "--rcon-port", str(self.rcon_port), + "--rcon-password", self.rcon_password, + "--server-settings", self.server_settings_path, + *self.additional_factorio_server_args) + else: + return ("--rcon-port", str(self.rcon_port), "--rcon-password", self.rcon_password, + *self.additional_factorio_server_args) + @property def energy_link_status(self) -> str: if not self.energy_link_increment: @@ -311,7 +329,7 @@ async def factorio_server_watcher(ctx: FactorioContext): executable, "--create", savegame_name, "--preset", "archipelago" )) factorio_process = subprocess.Popen((executable, "--start-server", savegame_name, - *(str(elem) for elem in server_args)), + *ctx.server_args), stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, @@ -331,7 +349,7 @@ async def factorio_server_watcher(ctx: FactorioContext): factorio_queue.task_done() if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg: - ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password, + ctx.rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password, timeout=5) if not ctx.server: logger.info("Established bridge to Factorio Server. " @@ -422,7 +440,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: executable, "--create", savegame_name )) factorio_process = subprocess.Popen( - (executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)), + (executable, "--start-server", savegame_name, *ctx.server_args), stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, @@ -451,7 +469,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: "or a Factorio sharing data directories is already running. " "Server could not start up.") if not rcon_client and "Starting RCON interface at IP ADDR:" in msg: - rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) + rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password) if ctx.mod_version == ctx.__class__.mod_version: raise Exception("No Archipelago mod was loaded. Aborting.") await get_info(ctx, rcon_client) @@ -474,9 +492,8 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: return False -async def main(args, filter_item_sends: bool, filter_bridge_chat_out: bool): - ctx = FactorioContext(args.connect, args.password, filter_item_sends, filter_bridge_chat_out) - +async def main(make_context): + ctx = make_context() ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") if gui_enabled: @@ -509,38 +526,42 @@ class FactorioJSONtoTextParser(JSONtoTextParser): return self._handle_text(node) -parser = get_base_parser(description="Optional arguments to FactorioClient follow. " - "Remaining arguments get passed into bound Factorio instance." - "Refer to Factorio --help for those.") -parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') -parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') -parser.add_argument('--server-settings', help='Factorio server settings configuration file.') - -args, rest = parser.parse_known_args() -rcon_port = args.rcon_port -rcon_password = args.rcon_password if args.rcon_password else ''.join( - random.choice(string.ascii_letters) for x in range(32)) factorio_server_logger = logging.getLogger("FactorioServer") settings: FactorioSettings = get_settings().factorio_options if os.path.samefile(settings.executable, sys.executable): selected_executable = settings.executable settings.executable = FactorioSettings.executable # reset to default - raise Exception(f"FactorioClient was set to run itself {selected_executable}, aborting process bomb.") + raise Exception(f"Factorio Client was set to run itself {selected_executable}, aborting process bomb.") executable = settings.executable -server_settings = args.server_settings if args.server_settings \ - else getattr(settings, "server_settings", None) -server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password) - -def launch(): +def launch(*new_args: str): import colorama - global executable, server_settings, server_args + global executable colorama.just_fix_windows_console() + # args handling + parser = get_base_parser(description="Optional arguments to Factorio Client follow. " + "Remaining arguments get passed into bound Factorio instance." + "Refer to Factorio --help for those.") + parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') + parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') + parser.add_argument('--server-settings', help='Factorio server settings configuration file.') + + args, rest = parser.parse_known_args(args=new_args) + rcon_port = args.rcon_port + rcon_password = args.rcon_password if args.rcon_password else ''.join( + random.choice(string.ascii_letters) for _ in range(32)) + + server_settings = args.server_settings if args.server_settings \ + else getattr(settings, "server_settings", None) + if server_settings: server_settings = os.path.abspath(server_settings) + if not os.path.isfile(server_settings): + raise FileNotFoundError(f"Could not find file {server_settings} for server_settings. Aborting.") + initial_filter_item_sends = bool(settings.filter_item_sends) initial_bridge_chat_out = bool(settings.bridge_chat_out) @@ -554,14 +575,9 @@ def launch(): else: raise FileNotFoundError(f"Path {executable} is not an executable file.") - if server_settings and os.path.isfile(server_settings): - server_args = ( - "--rcon-port", rcon_port, - "--rcon-password", rcon_password, - "--server-settings", server_settings, - *rest) - else: - server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest) - - asyncio.run(main(args, initial_filter_item_sends, initial_bridge_chat_out)) + asyncio.run(main(lambda: FactorioContext( + args.connect, args.password, + initial_filter_item_sends, initial_bridge_chat_out, + rcon_port, rcon_password, server_settings, rest + ))) colorama.deinit() diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index eb305897f4..3cc156112d 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -67,6 +67,7 @@ class FactorioModFile(worlds.Files.APPlayerContainer): game = "Factorio" compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]] + patch_file_ending = ".zip" def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index bfa6ceb894..8dc654099b 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -22,9 +22,9 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table from .settings import FactorioSettings -def launch_client(): +def launch_client(*args: str): from .Client import launch - launch_component(launch, name="FactorioClient") + launch_component(launch, name="Factorio Client", args=args) components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT)) diff --git a/worlds/jakanddaxter/__init__.py b/worlds/jakanddaxter/__init__.py index d508e967ae..9a2cb30293 100644 --- a/worlds/jakanddaxter/__init__.py +++ b/worlds/jakanddaxter/__init__.py @@ -34,9 +34,9 @@ from .locations import (JakAndDaxterLocation, cache_location_table, orb_location_table) from .regions import create_regions -from .rules import (enforce_multiplayer_limits, - enforce_singleplayer_limits, - verify_orb_trade_amounts, +from .rules import (enforce_mp_absolute_limits, + enforce_mp_friendly_limits, + enforce_sp_limits, set_orb_trade_rule) from .locs import (cell_locations as cells, scout_locations as scouts, @@ -258,18 +258,31 @@ class JakAndDaxterWorld(World): self.options.mountain_pass_cell_count.value = self.power_cell_thresholds[1] self.options.lava_tube_cell_count.value = self.power_cell_thresholds[2] - # Store this for remove function. - self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds] - - # For the fairness of other players in a multiworld game, enforce some friendly limitations on our options, - # so we don't cause chaos during seed generation. These friendly limits should **guarantee** a successful gen. - # We would have done this earlier, but we needed to sort the power cell thresholds first. + # We would have done this earlier, but we needed to sort the power cell thresholds first. Don't worry, we'll + # come back to them. enforce_friendly_options = self.settings.enforce_friendly_options - if enforce_friendly_options: - if self.multiworld.players > 1: - enforce_multiplayer_limits(self) + if self.multiworld.players == 1: + # For singleplayer games, always enforce/clamp the cell counts to valid values. + enforce_sp_limits(self) + else: + if enforce_friendly_options: + # For multiplayer games, we have a host setting to make options fair/sane for other players. + # If this setting is enabled, enforce/clamp some friendly limitations on our options. + enforce_mp_friendly_limits(self) else: - enforce_singleplayer_limits(self) + # Even if the setting is disabled, some values must be clamped to avoid generation errors. + enforce_mp_absolute_limits(self) + + # That's right, set the collection of thresholds again. Don't just clamp the values without updating this list! + self.power_cell_thresholds = [ + self.options.fire_canyon_cell_count.value, + self.options.mountain_pass_cell_count.value, + self.options.lava_tube_cell_count.value, + 100, # The 100 Power Cell Door. + ] + + # Now that the threshold list is finalized, store this for the remove function. + self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds] # Calculate the number of power cells needed for full region access, the number being replaced by traps, # and the number of remaining filler. @@ -282,11 +295,6 @@ class JakAndDaxterWorld(World): self.options.filler_power_cells_replaced_with_traps.value = self.total_trap_cells self.total_filler_cells = non_prog_cells - self.total_trap_cells - # Verify that we didn't overload the trade amounts with more orbs than exist in the world. - # This is easy to do by accident even in a singleplayer world. - self.total_trade_orbs = (9 * self.options.citizen_orb_trade_amount) + (6 * self.options.oracle_orb_trade_amount) - verify_orb_trade_amounts(self) - # Cache the orb bundle size and item name for quicker reference. if self.options.enable_orbsanity == options.EnableOrbsanity.option_per_level: self.orb_bundle_size = self.options.level_orbsanity_bundle_size.value diff --git a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md index 6cf8ae54a5..77fbd514cb 100644 --- a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md +++ b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md @@ -18,7 +18,7 @@ - [What do Traps do?](#what-do-traps-do) - [What kind of Traps are there?](#what-kind-of-traps-are-there) - [I got soft-locked and cannot leave, how do I get out of here?](#i-got-soft-locked-and-cannot-leave-how-do-i-get-out-of-here) -- [Why did I get an Option Error when generating a seed, and how do I fix it?](#why-did-i-get-an-option-error-when-generating-a-seed-and-how-do-i-fix-it) +- [How do I generate seeds with 1 Orb Orbsanity and other extreme options?](#how-do-i-generate-seeds-with-1-orb-orbsanity-and-other-extreme-options) - [How do I check my player options in-game?](#how-do-i-check-my-player-options-in-game) - [How does the HUD work?](#how-does-the-hud-work) - [I think I found a bug, where should I report it?](#i-think-i-found-a-bug-where-should-i-report-it) @@ -201,16 +201,19 @@ Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `W Selecting this option will ask if you want to be teleported to Geyser Rock. From there, you can teleport back to the nearest sage's hut to continue your journey. -## Why did I get an Option Error when generating a seed and how do I fix it +## How do I generate seeds with 1 orb orbsanity and other extreme options? Depending on your player YAML, Jak and Daxter can have a lot of items, which can sometimes be overwhelming or disruptive to multiworld games. There are also options that are mutually incompatible with each other, even in a solo game. To prevent the game from disrupting multiworlds, or generating an impossible solo seed, some options have -Singleplayer and Multiplayer Minimums and Maximums, collectively called "friendly limits." +"friendly limits" that prevent you from choosing more extreme values. -If you're generating a solo game, or your multiworld host agrees to your request, you can override those limits by -editing the `host.yaml`. In the Archipelago Launcher, click `Open host.yaml`, then search for `jakanddaxter_options`, -then search for `enforce_friendly_options`, then change this value from `true` to `false`. Disabling this allows for -more disruptive and challenging options, but it may cause seed generation to fail. **Use at your own risk!** +You can override **some**, not all, of those limits by editing the `host.yaml`. In the Archipelago Launcher, click +`Open host.yaml`, then search for `jakanddaxter_options`, then search for `enforce_friendly_options`, then change this +value from `true` to `false`. You can then generate a seed locally, and upload that to the Archipelago website to host +for you (or host it yourself). + +**Remember:** disabling this setting allows for more disruptive and challenging options, but it may cause seed +generation to fail. **Use at your own risk!** ## How do I check my player options in-game When you connect your text client to the Archipelago Server, the server will tell the game what options were chosen diff --git a/worlds/jakanddaxter/docs/setup_en.md b/worlds/jakanddaxter/docs/setup_en.md index 509fb3ad8d..9cd892a9b2 100644 --- a/worlds/jakanddaxter/docs/setup_en.md +++ b/worlds/jakanddaxter/docs/setup_en.md @@ -4,7 +4,6 @@ - A legally purchased copy of *Jak And Daxter: The Precursor Legacy.* - [The OpenGOAL Launcher](https://opengoal.dev/) -- [The Jak and Daxter .APWORLD package](https://github.com/ArchipelaGOAL/Archipelago/releases) At this time, this method of setup works on Windows only, but Linux support is a strong likelihood in the near future as OpenGOAL itself supports Linux. @@ -75,7 +74,7 @@ If you are in the middle of an async game, and you do not want to update the mod ### New Game - Run the Archipelago Launcher. -- From the right-most list, find and click `Jak and Daxter Client`. +- From the client list, find and click `Jak and Daxter Client`. - 3 new windows should appear: - The OpenGOAL compiler will launch and compile the game. They should take about 30 seconds to compile. - You should hear a musical cue to indicate the compilation was a success. If you do not, see the Troubleshooting section. diff --git a/worlds/jakanddaxter/options.py b/worlds/jakanddaxter/options.py index bd007e264a..d36303b075 100644 --- a/worlds/jakanddaxter/options.py +++ b/worlds/jakanddaxter/options.py @@ -1,22 +1,78 @@ from dataclasses import dataclass from functools import cached_property -from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionCounter +from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionCounter, \ + AssembleOptions from .items import trap_item_table -class StaticGetter: - def __init__(self, func): - self.fget = func +class readonly_classproperty: + """This decorator is used for getting friendly or unfriendly range_end values for options like FireCanyonCellCount + and CitizenOrbTradeAmount. We only need to provide a getter as we will only be setting a single int to one of two + values.""" + def __init__(self, getter): + self.getter = getter def __get__(self, instance, owner): - return self.fget(owner) + return self.getter(owner) -@StaticGetter +@readonly_classproperty def determine_range_end(cls) -> int: - from . import JakAndDaxterWorld - enforce_friendly_options = JakAndDaxterWorld.settings.enforce_friendly_options - return cls.friendly_maximum if enforce_friendly_options else cls.absolute_maximum + from . import JakAndDaxterWorld # Avoid circular imports. + friendly = JakAndDaxterWorld.settings.enforce_friendly_options + return cls.friendly_maximum if friendly else cls.absolute_maximum + + +class classproperty: + """This decorator (?) is used for getting and setting friendly or unfriendly option values for the Orbsanity + options.""" + def __init__(self, getter, setter): + self.getter = getter + self.setter = setter + + def __get__(self, obj, value): + return self.getter(obj) + + def __set__(self, obj, value): + self.setter(obj, value) + + +class AllowedChoiceMeta(AssembleOptions): + """This metaclass overrides AssembleOptions and provides inheriting classes a way to filter out "disallowed" values + by way of implementing get_disallowed_options. This function is used by Jak and Daxter to check host.yaml settings + without circular imports or breaking the settings API.""" + _name_lookup: dict[int, str] + _options: dict[str, int] + + def __new__(mcs, name, bases, attrs): + ret = super().__new__(mcs, name, bases, attrs) + ret._name_lookup = attrs["name_lookup"] + ret._options = attrs["options"] + return ret + + def set_name_lookup(cls, value : dict[int, str]): + cls._name_lookup = value + + def get_name_lookup(cls) -> dict[int, str]: + cls._name_lookup = {k: v for k, v in cls._name_lookup.items() if k not in cls.get_disallowed_options()} + return cls._name_lookup + + def set_options(cls, value: dict[str, int]): + cls._options = value + + def get_options(cls) -> dict[str, int]: + cls._options = {k: v for k, v in cls._options.items() if v not in cls.get_disallowed_options()} + return cls._options + + def get_disallowed_options(cls): + return {} + + name_lookup = classproperty(get_name_lookup, set_name_lookup) + options = classproperty(get_options, set_options) + + +class AllowedChoice(Choice, metaclass=AllowedChoiceMeta): + pass class EnableMoveRandomizer(Toggle): @@ -44,12 +100,13 @@ class EnableOrbsanity(Choice): default = 0 -class GlobalOrbsanityBundleSize(Choice): +class GlobalOrbsanityBundleSize(AllowedChoice): """The orb bundle size for Global Orbsanity. This only applies if "Enable Orbsanity" is set to "Global." There are 2000 orbs in the game, so your bundle size must be a factor of 2000. - Multiplayer Minimum: 10 - Multiplayer Maximum: 200""" + This value is restricted to safe minimum and maximum values to ensure valid singleplayer games and + non-disruptive multiplayer games, but the host can remove this restriction by turning off enforce_friendly_options + in host.yaml.""" display_name = "Global Orbsanity Bundle Size" option_1_orb = 1 option_2_orbs = 2 @@ -75,12 +132,33 @@ class GlobalOrbsanityBundleSize(Choice): friendly_maximum = 200 default = 20 + @classmethod + def get_disallowed_options(cls) -> set[int]: + try: + from . import JakAndDaxterWorld + if JakAndDaxterWorld.settings.enforce_friendly_options: + return {cls.option_1_orb, + cls.option_2_orbs, + cls.option_4_orbs, + cls.option_5_orbs, + cls.option_8_orbs, + cls.option_250_orbs, + cls.option_400_orbs, + cls.option_500_orbs, + cls.option_1000_orbs, + cls.option_2000_orbs} + except ImportError: + pass + return set() -class PerLevelOrbsanityBundleSize(Choice): + +class PerLevelOrbsanityBundleSize(AllowedChoice): """The orb bundle size for Per Level Orbsanity. This only applies if "Enable Orbsanity" is set to "Per Level." There are 50, 150, or 200 orbs per level, so your bundle size must be a factor of 50. - Multiplayer Minimum: 10""" + This value is restricted to safe minimum and maximum values to ensure valid singleplayer games and + non-disruptive multiplayer games, but the host can remove this restriction by turning off enforce_friendly_options + in host.yaml.""" display_name = "Per Level Orbsanity Bundle Size" option_1_orb = 1 option_2_orbs = 2 @@ -91,6 +169,18 @@ class PerLevelOrbsanityBundleSize(Choice): friendly_minimum = 10 default = 25 + @classmethod + def get_disallowed_options(cls) -> set[int]: + try: + from . import JakAndDaxterWorld + if JakAndDaxterWorld.settings.enforce_friendly_options: + return {cls.option_1_orb, + cls.option_2_orbs, + cls.option_5_orbs} + except ImportError: + pass + return set() + class FireCanyonCellCount(Range): """The number of power cells you need to cross Fire Canyon. This value is restricted to a safe maximum value to @@ -234,7 +324,7 @@ class CompletionCondition(Choice): option_cross_fire_canyon = 69 option_cross_mountain_pass = 87 option_cross_lava_tube = 89 - option_defeat_dark_eco_plant = 6 + # option_defeat_dark_eco_plant = 6 option_defeat_klaww = 86 option_defeat_gol_and_maia = 112 option_open_100_cell_door = 116 diff --git a/worlds/jakanddaxter/regions.py b/worlds/jakanddaxter/regions.py index 8447f72e8e..87186c3a02 100644 --- a/worlds/jakanddaxter/regions.py +++ b/worlds/jakanddaxter/regions.py @@ -115,8 +115,8 @@ def create_regions(world: "JakAndDaxterWorld"): elif options.jak_completion_condition == CompletionCondition.option_cross_lava_tube: multiworld.completion_condition[player] = lambda state: state.can_reach(gmc, "Region", player) - elif options.jak_completion_condition == CompletionCondition.option_defeat_dark_eco_plant: - multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player) + # elif options.jak_completion_condition == CompletionCondition.option_defeat_dark_eco_plant: + # multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player) elif options.jak_completion_condition == CompletionCondition.option_defeat_klaww: multiworld.completion_condition[player] = lambda state: state.can_reach(mp, "Region", player) diff --git a/worlds/jakanddaxter/rules.py b/worlds/jakanddaxter/rules.py index 71b94df885..25a8323f4d 100644 --- a/worlds/jakanddaxter/rules.py +++ b/worlds/jakanddaxter/rules.py @@ -1,3 +1,5 @@ +import logging +import math import typing from BaseClasses import CollectionState from Options import OptionError @@ -131,100 +133,138 @@ def can_fight(state: CollectionState, player: int) -> bool: return state.has_any(("Jump Dive", "Jump Kick", "Punch", "Kick"), player) -def enforce_multiplayer_limits(world: "JakAndDaxterWorld"): +def clamp_cell_limits(world: "JakAndDaxterWorld") -> str: options = world.options friendly_message = "" - if (options.enable_orbsanity == EnableOrbsanity.option_global - and (options.global_orbsanity_bundle_size.value < GlobalOrbsanityBundleSize.friendly_minimum - or options.global_orbsanity_bundle_size.value > GlobalOrbsanityBundleSize.friendly_maximum)): - friendly_message += (f" " - f"{options.global_orbsanity_bundle_size.display_name} must be no less than " - f"{GlobalOrbsanityBundleSize.friendly_minimum} and no greater than " - f"{GlobalOrbsanityBundleSize.friendly_maximum} (currently " - f"{options.global_orbsanity_bundle_size.value}).\n") - - if (options.enable_orbsanity == EnableOrbsanity.option_per_level - and options.level_orbsanity_bundle_size.value < PerLevelOrbsanityBundleSize.friendly_minimum): - friendly_message += (f" " - f"{options.level_orbsanity_bundle_size.display_name} must be no less than " - f"{PerLevelOrbsanityBundleSize.friendly_minimum} (currently " - f"{options.level_orbsanity_bundle_size.value}).\n") - if options.fire_canyon_cell_count.value > FireCanyonCellCount.friendly_maximum: + old_value = options.fire_canyon_cell_count.value + options.fire_canyon_cell_count.value = FireCanyonCellCount.friendly_maximum friendly_message += (f" " f"{options.fire_canyon_cell_count.display_name} must be no greater than " - f"{FireCanyonCellCount.friendly_maximum} (currently " - f"{options.fire_canyon_cell_count.value}).\n") + f"{FireCanyonCellCount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.mountain_pass_cell_count.value > MountainPassCellCount.friendly_maximum: + old_value = options.mountain_pass_cell_count.value + options.mountain_pass_cell_count.value = MountainPassCellCount.friendly_maximum friendly_message += (f" " f"{options.mountain_pass_cell_count.display_name} must be no greater than " - f"{MountainPassCellCount.friendly_maximum} (currently " - f"{options.mountain_pass_cell_count.value}).\n") + f"{MountainPassCellCount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.lava_tube_cell_count.value > LavaTubeCellCount.friendly_maximum: + old_value = options.lava_tube_cell_count.value + options.lava_tube_cell_count.value = LavaTubeCellCount.friendly_maximum friendly_message += (f" " f"{options.lava_tube_cell_count.display_name} must be no greater than " - f"{LavaTubeCellCount.friendly_maximum} (currently " - f"{options.lava_tube_cell_count.value}).\n") + f"{LavaTubeCellCount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") + + return friendly_message + + +def clamp_trade_total_limits(world: "JakAndDaxterWorld"): + """Check if we need to recalculate the 2 trade orb options so the total fits under 2000. If so let's keep them + proportional relative to each other. Then we'll recalculate total_trade_orbs. Remember this situation is + only possible if both values are greater than 0, otherwise the absolute maximums would keep them under 2000.""" + options = world.options + friendly_message = "" + + world.total_trade_orbs = (9 * options.citizen_orb_trade_amount) + (6 * options.oracle_orb_trade_amount) + if world.total_trade_orbs > 2000: + old_total = world.total_trade_orbs + old_citizen_value = options.citizen_orb_trade_amount.value + old_oracle_value = options.oracle_orb_trade_amount.value + + coefficient = old_oracle_value / old_citizen_value + + options.citizen_orb_trade_amount.value = math.floor(2000 / (9 + (6 * coefficient))) + options.oracle_orb_trade_amount.value = math.floor(coefficient * options.citizen_orb_trade_amount.value) + world.total_trade_orbs = (9 * options.citizen_orb_trade_amount) + (6 * options.oracle_orb_trade_amount) + + friendly_message += (f" " + f"Required number of orbs ({old_total}) must be no greater than total orbs in the game " + f"(2000). Reduced the value of {world.options.citizen_orb_trade_amount.display_name} " + f"from {old_citizen_value} to {options.citizen_orb_trade_amount.value} and " + f"{world.options.oracle_orb_trade_amount.display_name} from {old_oracle_value} to " + f"{options.oracle_orb_trade_amount.value}.\n") + + return friendly_message + + +def enforce_mp_friendly_limits(world: "JakAndDaxterWorld"): + options = world.options + friendly_message = "" + + if options.enable_orbsanity == EnableOrbsanity.option_global: + if options.global_orbsanity_bundle_size.value < GlobalOrbsanityBundleSize.friendly_minimum: + old_value = options.global_orbsanity_bundle_size.value + options.global_orbsanity_bundle_size.value = GlobalOrbsanityBundleSize.friendly_minimum + friendly_message += (f" " + f"{options.global_orbsanity_bundle_size.display_name} must be no less than " + f"{GlobalOrbsanityBundleSize.friendly_minimum} (was {old_value}), " + f"changed option to appropriate value.\n") + + if options.global_orbsanity_bundle_size.value > GlobalOrbsanityBundleSize.friendly_maximum: + old_value = options.global_orbsanity_bundle_size.value + options.global_orbsanity_bundle_size.value = GlobalOrbsanityBundleSize.friendly_maximum + friendly_message += (f" " + f"{options.global_orbsanity_bundle_size.display_name} must be no greater than " + f"{GlobalOrbsanityBundleSize.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") + + if options.enable_orbsanity == EnableOrbsanity.option_per_level: + if options.level_orbsanity_bundle_size.value < PerLevelOrbsanityBundleSize.friendly_minimum: + old_value = options.level_orbsanity_bundle_size.value + options.level_orbsanity_bundle_size.value = PerLevelOrbsanityBundleSize.friendly_minimum + friendly_message += (f" " + f"{options.level_orbsanity_bundle_size.display_name} must be no less than " + f"{PerLevelOrbsanityBundleSize.friendly_minimum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.citizen_orb_trade_amount.value > CitizenOrbTradeAmount.friendly_maximum: + old_value = options.citizen_orb_trade_amount.value + options.citizen_orb_trade_amount.value = CitizenOrbTradeAmount.friendly_maximum friendly_message += (f" " f"{options.citizen_orb_trade_amount.display_name} must be no greater than " - f"{CitizenOrbTradeAmount.friendly_maximum} (currently " - f"{options.citizen_orb_trade_amount.value}).\n") + f"{CitizenOrbTradeAmount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") if options.oracle_orb_trade_amount.value > OracleOrbTradeAmount.friendly_maximum: + old_value = options.oracle_orb_trade_amount.value + options.oracle_orb_trade_amount.value = OracleOrbTradeAmount.friendly_maximum friendly_message += (f" " f"{options.oracle_orb_trade_amount.display_name} must be no greater than " - f"{OracleOrbTradeAmount.friendly_maximum} (currently " - f"{options.oracle_orb_trade_amount.value}).\n") + f"{OracleOrbTradeAmount.friendly_maximum} (was {old_value}), " + f"changed option to appropriate value.\n") + + friendly_message += clamp_cell_limits(world) + friendly_message += clamp_trade_total_limits(world) if friendly_message != "": - raise OptionError(f"{world.player_name}: The options you have chosen may disrupt the multiworld. \n" - f"Please adjust the following Options for a multiplayer game. \n" - f"{friendly_message}" - f"Or use 'random-range-x-y' instead of 'random' in your player yaml.\n" - f"Or set 'enforce_friendly_options' in the seed generator's host.yaml to false. " - f"(Use at your own risk!)") + logging.warning(f"{world.player_name}: Your options have been modified to avoid disrupting the multiworld.\n" + f"{friendly_message}" + f"You can access more advanced options by setting 'enforce_friendly_options' in the seed " + f"generator's host.yaml to false and generating locally. (Use at your own risk!)") -def enforce_singleplayer_limits(world: "JakAndDaxterWorld"): - options = world.options +def enforce_mp_absolute_limits(world: "JakAndDaxterWorld"): friendly_message = "" - if options.fire_canyon_cell_count.value > FireCanyonCellCount.friendly_maximum: - friendly_message += (f" " - f"{options.fire_canyon_cell_count.display_name} must be no greater than " - f"{FireCanyonCellCount.friendly_maximum} (currently " - f"{options.fire_canyon_cell_count.value}).\n") - - if options.mountain_pass_cell_count.value > MountainPassCellCount.friendly_maximum: - friendly_message += (f" " - f"{options.mountain_pass_cell_count.display_name} must be no greater than " - f"{MountainPassCellCount.friendly_maximum} (currently " - f"{options.mountain_pass_cell_count.value}).\n") - - if options.lava_tube_cell_count.value > LavaTubeCellCount.friendly_maximum: - friendly_message += (f" " - f"{options.lava_tube_cell_count.display_name} must be no greater than " - f"{LavaTubeCellCount.friendly_maximum} (currently " - f"{options.lava_tube_cell_count.value}).\n") + friendly_message += clamp_trade_total_limits(world) if friendly_message != "": - raise OptionError(f"The options you have chosen may result in seed generation failures. \n" - f"Please adjust the following Options for a singleplayer game. \n" - f"{friendly_message}" - f"Or use 'random-range-x-y' instead of 'random' in your player yaml.\n" - f"Or set 'enforce_friendly_options' in your host.yaml to false. " - f"(Use at your own risk!)") + logging.warning(f"{world.player_name}: Your options have been modified to avoid seed generation failures.\n" + f"{friendly_message}") -def verify_orb_trade_amounts(world: "JakAndDaxterWorld"): +def enforce_sp_limits(world: "JakAndDaxterWorld"): + friendly_message = "" - if world.total_trade_orbs > 2000: - raise OptionError(f"{world.player_name}: Required number of orbs for all trades ({world.total_trade_orbs}) " - f"is more than all the orbs in the game (2000). Reduce the value of either " - f"{world.options.citizen_orb_trade_amount.display_name} " - f"or {world.options.oracle_orb_trade_amount.display_name}.") + friendly_message += clamp_cell_limits(world) + friendly_message += clamp_trade_total_limits(world) + + if friendly_message != "": + logging.warning(f"{world.player_name}: Your options have been modified to avoid seed generation failures.\n" + f"{friendly_message}") diff --git a/worlds/jakanddaxter/test/test_trades.py b/worlds/jakanddaxter/test/test_trades.py index e1d1a2e53d..0277a92353 100644 --- a/worlds/jakanddaxter/test/test_trades.py +++ b/worlds/jakanddaxter/test/test_trades.py @@ -4,14 +4,14 @@ from .bases import JakAndDaxterTestBase class TradesCostNothingTest(JakAndDaxterTestBase): options = { "enable_orbsanity": 2, - "global_orbsanity_bundle_size": 5, + "global_orbsanity_bundle_size": 10, "citizen_orb_trade_amount": 0, "oracle_orb_trade_amount": 0 } def test_orb_items_are_filler(self): self.collect_all_but("") - self.assertNotIn("5 Precursor Orbs", self.multiworld.state.prog_items) + self.assertNotIn("10 Precursor Orbs", self.multiworld.state.prog_items) def test_trades_are_accessible(self): self.assertTrue(self.multiworld @@ -22,15 +22,15 @@ class TradesCostNothingTest(JakAndDaxterTestBase): class TradesCostEverythingTest(JakAndDaxterTestBase): options = { "enable_orbsanity": 2, - "global_orbsanity_bundle_size": 5, + "global_orbsanity_bundle_size": 10, "citizen_orb_trade_amount": 120, "oracle_orb_trade_amount": 150 } def test_orb_items_are_progression(self): self.collect_all_but("") - self.assertIn("5 Precursor Orbs", self.multiworld.state.prog_items[self.player]) - self.assertEqual(396, self.multiworld.state.prog_items[self.player]["5 Precursor Orbs"]) + self.assertIn("10 Precursor Orbs", self.multiworld.state.prog_items[self.player]) + self.assertEqual(198, self.multiworld.state.prog_items[self.player]["10 Precursor Orbs"]) def test_trades_are_accessible(self): self.collect_all_but("") diff --git a/worlds/kh2/OpenKH.py b/worlds/kh2/OpenKH.py index 985c9913ab..7c67fc07de 100644 --- a/worlds/kh2/OpenKH.py +++ b/worlds/kh2/OpenKH.py @@ -13,6 +13,7 @@ from worlds.Files import APPlayerContainer class KH2Container(APPlayerContainer): game: str = 'Kingdom Hearts 2' + patch_file_ending = ".zip" def __init__(self, patch_data: dict, base_path: str, output_directory: str, player=None, player_name: str = "", server: str = ""): diff --git a/worlds/oot/Patches.py b/worlds/oot/Patches.py index cd940e052a..db7be3d4dd 100644 --- a/worlds/oot/Patches.py +++ b/worlds/oot/Patches.py @@ -38,6 +38,7 @@ AP_JUNK = 0xD5 class OoTContainer(APPatch): game: str = 'Ocarina of Time' + patch_file_ending = ".apz5" def __init__(self, patch_data: bytes, base_path: str, output_directory: str, player = None, player_name: str = "", server: str = ""): diff --git a/worlds/shapez/test/__init__.py b/worlds/shapez/test/__init__.py index 3ab626e639..d2dfad97da 100644 --- a/worlds/shapez/test/__init__.py +++ b/worlds/shapez/test/__init__.py @@ -92,17 +92,7 @@ class TestGlobalOptionsImport(TestCase): f"{max_levels_and_upgrades} instead.") -class TestMinimum(ShapezTestBase): - options = options_presets["Minimum checks"] - - -class TestMaximum(ShapezTestBase): - options = options_presets["Maximum checks"] - - -class TestRestrictive(ShapezTestBase): - options = options_presets["Restrictive start"] - +# The following unittests are intended to test all code paths of the generator class TestAllRelevantOptions1(ShapezTestBase): options = { diff --git a/worlds/soe/docs/multiworld_en.md b/worlds/soe/docs/multiworld_en.md index a2944d4c01..9378626df4 100644 --- a/worlds/soe/docs/multiworld_en.md +++ b/worlds/soe/docs/multiworld_en.md @@ -130,9 +130,7 @@ page: [usb2snes Supported Platforms Page](http://usb2snes.com/#supported-platfor ### Open the client -Open ap-soeclient ([Evermizer Archipelago Client Page](http://evermizer.com/apclient)) in a modern browser. Do not -switch tabs, open it in a new window if you want to use the browser while playing. Do not minimize the window with the -client. +Open ap-soeclient ([Evermizer Archipelago Client Page](http://evermizer.com/apclient)) in a modern browser. The client should automatically connect to SNI, the "SNES" status should change to green. diff --git a/worlds/stardew_valley/content/mods/sve.py b/worlds/stardew_valley/content/mods/sve.py index 2c9edc8106..3784723737 100644 --- a/worlds/stardew_valley/content/mods/sve.py +++ b/worlds/stardew_valley/content/mods/sve.py @@ -216,7 +216,6 @@ register_mod_content_pack(SVEContentPack( villagers_data.scarlett, villagers_data.susan, villagers_data.morris, - # The wizard leaves his tower on sunday, for like 1 hour... Good enough for entrance rando! - override(villagers_data.wizard, locations=(Region.wizard_tower, Region.forest), bachelor=True, mod_name=ModNames.sve), + override(villagers_data.wizard, bachelor=True, mod_name=ModNames.sve), ) )) diff --git a/worlds/stardew_valley/logic/quest_logic.py b/worlds/stardew_valley/logic/quest_logic.py index 5bc3f86eae..af52d06e30 100644 --- a/worlds/stardew_valley/logic/quest_logic.py +++ b/worlds/stardew_valley/logic/quest_logic.py @@ -89,7 +89,7 @@ class QuestLogic(BaseLogic): Quest.goblin_problem: self.logic.region.can_reach(Region.witch_swamp) # Void mayo can be fished at 5% chance in the witch swamp while the quest is active. It drops a lot after the quest. & (self.logic.has(ArtisanGood.void_mayonnaise) | self.logic.fishing.can_fish()), - Quest.magic_ink: self.logic.relationship.can_meet(NPC.wizard), + Quest.magic_ink: self.logic.region.can_reach(Region.witch_hut) & self.logic.relationship.can_meet(NPC.wizard), Quest.the_pirates_wife: self.logic.relationship.can_meet(NPC.kent) & self.logic.relationship.can_meet(NPC.gus) & self.logic.relationship.can_meet(NPC.sandy) & self.logic.relationship.can_meet(NPC.george) & self.logic.relationship.can_meet(NPC.wizard) & self.logic.relationship.can_meet(NPC.willy), diff --git a/worlds/stardew_valley/logic/region_logic.py b/worlds/stardew_valley/logic/region_logic.py index 083f56e167..81c79be097 100644 --- a/worlds/stardew_valley/logic/region_logic.py +++ b/worlds/stardew_valley/logic/region_logic.py @@ -1,23 +1,23 @@ -from typing import Tuple, Union +from typing import Tuple from Utils import cache_self1 from .base_logic import BaseLogic, BaseLogicMixin -from .has_logic import HasLogicMixin from ..options import EntranceRandomization from ..stardew_rule import StardewRule, Reach, false_, true_ from ..strings.region_names import Region main_outside_area = {Region.menu, Region.stardew_valley, Region.farm_house, Region.farm, Region.town, Region.beach, Region.mountain, Region.forest, Region.bus_stop, Region.backwoods, Region.bus_tunnel, Region.tunnel_entrance} -always_accessible_regions_without_er = {*main_outside_area, Region.community_center, Region.pantry, Region.crafts_room, Region.fish_tank, Region.boiler_room, - Region.vault, Region.bulletin_board, Region.mines, Region.hospital, Region.carpenter, Region.alex_house, - Region.elliott_house, Region.ranch, Region.farm_cave, Region.wizard_tower, Region.tent, Region.pierre_store, - Region.saloon, Region.blacksmith, Region.trailer, Region.museum, Region.mayor_house, Region.haley_house, - Region.sam_house, Region.jojamart, Region.fish_shop} +always_accessible_regions_with_non_progression_er = {*main_outside_area, Region.mines, Region.hospital, Region.carpenter, Region.alex_house, + Region.ranch, Region.farm_cave, Region.wizard_tower, Region.tent, + Region.pierre_store, Region.saloon, Region.blacksmith, Region.trailer, Region.museum, Region.mayor_house, + Region.haley_house, Region.sam_house, Region.jojamart, Region.fish_shop} +always_accessible_regions_without_er = {*always_accessible_regions_with_non_progression_er, Region.community_center, Region.pantry, Region.crafts_room, + Region.fish_tank, Region.boiler_room, Region.vault, Region.bulletin_board} always_regions_by_setting = {EntranceRandomization.option_disabled: always_accessible_regions_without_er, EntranceRandomization.option_pelican_town: always_accessible_regions_without_er, - EntranceRandomization.option_non_progression: always_accessible_regions_without_er, + EntranceRandomization.option_non_progression: always_accessible_regions_with_non_progression_er, EntranceRandomization.option_buildings_without_house: main_outside_area, EntranceRandomization.option_buildings: main_outside_area, EntranceRandomization.option_chaos: always_accessible_regions_without_er} diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py index e5d7e8863e..350da064a1 100644 --- a/worlds/stardew_valley/rules.py +++ b/worlds/stardew_valley/rules.py @@ -195,6 +195,7 @@ def set_entrance_rules(logic: StardewLogic, multiworld, player, world_options: S set_entrance_rule(multiworld, player, Entrance.enter_tide_pools, logic.received("Beach Bridge") | (logic.mod.magic.can_blink())) set_entrance_rule(multiworld, player, Entrance.enter_quarry, logic.received("Bridge Repair") | (logic.mod.magic.can_blink())) set_entrance_rule(multiworld, player, Entrance.enter_secret_woods, logic.tool.has_tool(Tool.axe, "Iron") | (logic.mod.magic.can_blink())) + set_entrance_rule(multiworld, player, Entrance.forest_to_wizard_tower, logic.region.can_reach(Region.community_center)) set_entrance_rule(multiworld, player, Entrance.forest_to_sewer, logic.wallet.has_rusty_key()) set_entrance_rule(multiworld, player, Entrance.town_to_sewer, logic.wallet.has_rusty_key()) set_entrance_rule(multiworld, player, Entrance.enter_abandoned_jojamart, logic.has_abandoned_jojamart()) diff --git a/worlds/timespinner/PreCalculatedWeights.py b/worlds/timespinner/PreCalculatedWeights.py index 3ad7c2c78a..96551ea7f1 100644 --- a/worlds/timespinner/PreCalculatedWeights.py +++ b/worlds/timespinner/PreCalculatedWeights.py @@ -88,12 +88,15 @@ class PreCalculatedWeights: if options.risky_warps: past_teleportation_gates.append("GateLakeSereneLeft") - present_teleportation_gates.append("GateDadsTower") if not is_xarion_flooded: present_teleportation_gates.append("GateXarion") - if not is_lab_flooded: - present_teleportation_gates.append("GateLabEntrance") + # Prevent going past the lazers without a way to the past + if options.unchained_keys or options.prism_break or not options.pyramid_start: + present_teleportation_gates.append("GateDadsTower") + if not is_lab_flooded: + present_teleportation_gates.append("GateLabEntrance") + # Prevent getting stuck in the past without a way back to the future if options.inverted or (options.pyramid_start and not options.back_to_the_future): all_gates: Tuple[str, ...] = present_teleportation_gates else: diff --git a/worlds/timespinner/Regions.py b/worlds/timespinner/Regions.py index cb55d9810d..b9b1d10445 100644 --- a/worlds/timespinner/Regions.py +++ b/worlds/timespinner/Regions.py @@ -178,7 +178,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, options: Timesp connect(world, player, 'Space time continuum', 'Upper Lake Serene', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneLeft")) connect(world, player, 'Space time continuum', 'Left Side forest Caves', lambda state: logic.can_teleport_to(state, "Past", "GateLakeSereneRight")) connect(world, player, 'Space time continuum', 'Refugee Camp', lambda state: logic.can_teleport_to(state, "Past", "GateAccessToPast")) - connect(world, player, 'Space time continuum', 'Castle Ramparts', lambda state: logic.can_teleport_to(state, "Past", "GateCastleRamparts")) + connect(world, player, 'Space time continuum', 'Forest', lambda state: logic.can_teleport_to(state, "Past", "GateCastleRamparts")) connect(world, player, 'Space time continuum', 'Castle Keep', lambda state: logic.can_teleport_to(state, "Past", "GateCastleKeep")) connect(world, player, 'Space time continuum', 'Royal towers (lower)', lambda state: logic.can_teleport_to(state, "Past", "GateRoyalTowers")) connect(world, player, 'Space time continuum', 'Caves of Banishment (Maw)', lambda state: logic.can_teleport_to(state, "Past", "GateMaw")) diff --git a/worlds/timespinner/__init__.py b/worlds/timespinner/__init__.py index 4d1efc41e5..77314d40ec 100644 --- a/worlds/timespinner/__init__.py +++ b/worlds/timespinner/__init__.py @@ -42,6 +42,7 @@ class TimespinnerWorld(World): topology_present = True web = TimespinnerWebWorld() required_client_version = (0, 4, 2) + ut_can_gen_without_yaml = True item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = {location.name: location.code for location in get_location_datas(-1, None, None)} diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 8c0979e3e4..edd6021cba 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -56,18 +56,18 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ for portal1, portal2 in portal_pairs.items(): if portal1.scene_destination() == portal_sd: return portal1.name, get_portal_outlet_region(portal2, world) - if portal2.scene_destination() == portal_sd: + if portal2.scene_destination() == portal_sd and not (options.decoupled and options.entrance_rando): return portal2.name, get_portal_outlet_region(portal1, world) - raise Exception("No matches found in get_portal_info") + raise Exception(f"No matches found in get_portal_info for {portal_sd}") # input scene destination tag, returns paired portal's name and region def get_paired_portal(portal_sd: str) -> Tuple[str, str]: for portal1, portal2 in portal_pairs.items(): if portal1.scene_destination() == portal_sd: return portal2.name, portal2.region - if portal2.scene_destination() == portal_sd: + if portal2.scene_destination() == portal_sd and not (options.decoupled and options.entrance_rando): return portal1.name, portal1.region - raise Exception("no matches found in get_paired_portal") + raise Exception(f"No matches found in get_paired_portal for {portal_sd}") regions["Menu"].connect( connecting_region=regions["Overworld"]) diff --git a/worlds/tww/__init__.py b/worlds/tww/__init__.py index 71044d78a8..5432d200ae 100644 --- a/worlds/tww/__init__.py +++ b/worlds/tww/__init__.py @@ -11,7 +11,7 @@ from BaseClasses import ItemClassification as IC from BaseClasses import MultiWorld, Region, Tutorial from Options import Toggle from worlds.AutoWorld import WebWorld, World -from worlds.Files import APPlayerContainer, AutoPatchRegister +from worlds.Files import APPlayerContainer from worlds.generic.Rules import add_item_rule from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, icon_paths, launch_subprocess @@ -51,7 +51,7 @@ components.append( icon_paths["The Wind Waker"] = "ap:worlds.tww/assets/icon.png" -class TWWContainer(APPlayerContainer, metaclass=AutoPatchRegister): +class TWWContainer(APPlayerContainer): """ This class defines the container file for The Wind Waker. """