From d19bf98dc4d977372759e73bd97356f3c3cd08c4 Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Fri, 30 May 2025 10:31:00 -0400 Subject: [PATCH 01/15] Jak and Daxter: Post-merge Polish (#5031) - Cleans up a few missed references in the setup guide. - Refactors Options class to use metaclass and decorators to enforce friendly limits on multiple levels. - Templates generated from the website, even ones with `random` should not fail generation because the website will only allow values inside the friendly limits. - _Uploaded_ yamls to the website with `random`, should also now respect friendly limits without the need for `random-range` shenanigans. - _Uploaded_ yamls to the website, or yamls that are used to generate locally, that have hard-defined values outside the friendly limits, will be clamped/dragged/massaged into those limits (with logged warnings). - Removed an early completion goal that was playing havoc with fill. Not enough people seem to use this goal, so its loss will not be mourned. --- worlds/jakanddaxter/__init__.py | 44 +++-- .../en_Jak and Daxter The Precursor Legacy.md | 17 +- worlds/jakanddaxter/docs/setup_en.md | 3 +- worlds/jakanddaxter/options.py | 120 +++++++++++-- worlds/jakanddaxter/regions.py | 4 +- worlds/jakanddaxter/rules.py | 168 +++++++++++------- worlds/jakanddaxter/test/test_trades.py | 10 +- 7 files changed, 253 insertions(+), 113 deletions(-) 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("") From fab75d3a32ee16198b7bc67215dcef252e726250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bolduc?= <16137441+Jouramie@users.noreply.github.com> Date: Sat, 31 May 2025 07:57:42 -0400 Subject: [PATCH 02/15] Stardew Valley: Fix Wizard Tower and Entrance Randomizer Softlocks (#4631) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/stardew_valley/content/mods/sve.py | 3 +-- worlds/stardew_valley/logic/quest_logic.py | 2 +- worlds/stardew_valley/logic/region_logic.py | 16 ++++++++-------- worlds/stardew_valley/rules.py | 1 + 4 files changed, 11 insertions(+), 11 deletions(-) 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()) From 8f68bb342dcd9e4a38f41bbeb605a6e5c81319a4 Mon Sep 17 00:00:00 2001 From: qwint Date: Mon, 2 Jun 2025 10:53:18 -0500 Subject: [PATCH 03/15] Core and Various Worlds: define patch_file_ending to APPlayerContainer (#5058) * move to playercontainer * moves patch_file_ending handling to APPlayerContainer and updates the worlds using it to define their extensions * give oot a patch_file_ending as well --- worlds/Files.py | 4 ++-- worlds/civ_6/Container.py | 18 +++++++----------- worlds/factorio/Mod.py | 1 + worlds/kh2/OpenKH.py | 1 + worlds/oot/Patches.py | 1 + worlds/tww/__init__.py | 4 ++-- 6 files changed, 14 insertions(+), 15 deletions(-) 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/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/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/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. """ From cabde313b563990171c39bf2b644bf8f779df81b Mon Sep 17 00:00:00 2001 From: qwint Date: Mon, 2 Jun 2025 10:53:57 -0500 Subject: [PATCH 04/15] WebHost: Use expected APPlayerContainer manifest location directly when ingesting them #4754 --- WebHostLib/upload.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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"): From 0c5cb17d96af6091a5c35f13cc2fc62551679b09 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Mon, 2 Jun 2025 16:56:11 +0100 Subject: [PATCH 05/15] DLCQuest: Add missing indirect conditions (#5074) The `Behind Rocks` and `Pickaxe Hard Cave` Entrances require being able to reach the `Cut Content` region, but no indirect conditions were being registered for this region. The `set_lfod_self_obtained_items_rules` function was also using a `world` parameter that was actually expecting a `MultiWorld` instance, so I have renamed it for clarity and updated the function to use `world.get_entrance()` rather than `multiworld.get_entrance()`. Much of the rest of the file passes `MultiWorld` instances to `world` parameters, but fixing all of these is out of the scope of the changes in this patch, so has not been included. --- worlds/dlcquest/Rules.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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): From 99142fd6625b3bf3ef013bbd3bc1813c5d95923f Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Mon, 2 Jun 2025 12:01:21 -0400 Subject: [PATCH 06/15] Plando Items: Fix count with empty locations/location #5040 --- Options.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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): From 04c707f8740c25373f090c8f03199d8c56f067de Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Mon, 2 Jun 2025 17:06:54 +0100 Subject: [PATCH 07/15] DKC3: Add missing indirect conditions (#5073) A couple of Entrance access rules were checking for being able to reach a Location, but a Location first checks for being able to reach its parent Region, so it needs to be registered that access to that parent Region can give access to the Entrance. --- worlds/dkc3/Regions.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 From b85b18cf5fb5db4dbcbd736ff4a3dafcfeeb0f3b Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Mon, 2 Jun 2025 16:39:42 +0000 Subject: [PATCH 08/15] SoE: remove outdated info from guide (#5064) The client does not depend on Animation Frame anymore, so it can be backgrounded. --- worlds/soe/docs/multiworld_en.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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. From 694e6bcae36bab4e49c60c3c8f097d2f440a0979 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:42:37 +0000 Subject: [PATCH 09/15] Launcher/Utils: reset LD_LIBRARY_PATH for system EXEs (#5022) --- Launcher.py | 15 ++++++++++++--- Utils.py | 36 ++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 19 deletions(-) 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/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 From a76cec15397efc4758fc1d487fc5cbc97e663d7c Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Tue, 3 Jun 2025 06:51:06 -0400 Subject: [PATCH 10/15] =?UTF-8?q?TUNIC:=20Fix=20decoupled=20ER=20+=20ladde?= =?UTF-8?q?r=20storage=20making=20invalid=20entrances=C2=A0#5075?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/tunic/er_rules.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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"]) From b4f68bce7671e83e1fd4a358e506705777228eb6 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Tue, 3 Jun 2025 13:49:44 +0200 Subject: [PATCH 11/15] Factorio: revamp args parsing and passing (#5036) --- worlds/factorio/Client.py | 88 ++++++++++++++++++++++--------------- worlds/factorio/__init__.py | 4 +- 2 files changed, 54 insertions(+), 38 deletions(-) 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/__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)) From 603a5005e2f5d055f1b66ac5d75c133459240868 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Tue, 3 Jun 2025 08:49:10 -0400 Subject: [PATCH 12/15] DS3: Fix Non-Crow Itemlinking and Mark Aldrich Ruby and Twin Dragon Greatshield As Missable (#4510) * Fix Branch (Not Crow) * Oops * Mark Aldrich Ruby as missable * Expand comment * Short circuit * Mark Twin Dragon Greatshield as missable * Add missable cause --- worlds/dark_souls_3/Locations.py | 4 ++-- worlds/dark_souls_3/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 From a2708edc37ff98e70b58f0f552deb14282f5a7ad Mon Sep 17 00:00:00 2001 From: Ehseezed <97066152+Ehseezed@users.noreply.github.com> Date: Wed, 4 Jun 2025 12:51:08 -0500 Subject: [PATCH 13/15] Timespinner: Fix Castle Ramparts Region Connection #5082 Co-authored-by: ehseezed --- worlds/timespinner/Regions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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")) From 50db922cefbde107dd33d588ce621336b956a397 Mon Sep 17 00:00:00 2001 From: Jarno Date: Thu, 5 Jun 2025 15:05:00 +0200 Subject: [PATCH 14/15] Timespinner: Fixed generation error because of timezone locking (#5084) * Fixed generation error because of timezone locking * Refactored logic + prevent excluding warps when unchained keys in on --- worlds/timespinner/PreCalculatedWeights.py | 9 ++++++--- worlds/timespinner/__init__.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) 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/__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)} From ab7d3ce4aadfc647c42c8d4dd0c7aa10f9e0e49d Mon Sep 17 00:00:00 2001 From: BlastSlimey <89539656+BlastSlimey@users.noreply.github.com> Date: Fri, 6 Jun 2025 00:05:53 +0200 Subject: [PATCH 15/15] shapez: Remove preset unittests #5086 --- worlds/shapez/test/__init__.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) 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 = {