From 04a3f78605fa55e9f8fd3177d0229d48b8d7890d Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:29:21 +0200 Subject: [PATCH 01/29] Core: Support inequality operators ("less than") for Choice option string comparisons (#3769) * add some unit tests to it * fix * Update Options.py Co-authored-by: qwint * Update Options.py --------- Co-authored-by: qwint --- Options.py | 24 ++++++++++++++++++++++++ test/options/test_option_classes.py | 9 +++++++++ worlds/witness/__init__.py | 2 +- worlds/witness/hints.py | 2 +- worlds/witness/player_logic.py | 4 ++-- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Options.py b/Options.py index c948e7e95f..3e67d68569 100644 --- a/Options.py +++ b/Options.py @@ -494,6 +494,30 @@ class Choice(NumericOption): else: raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") + def __lt__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} < {other}" + other = self.options[other] + return super(Choice, self).__lt__(other) + + def __gt__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} > {other}" + other = self.options[other] + return super(Choice, self).__gt__(other) + + def __le__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} <= {other}" + other = self.options[other] + return super(Choice, self).__le__(other) + + def __ge__(self, other: typing.Union[Choice, int, str]): + if isinstance(other, str): + assert other in self.options, f"compared against an unknown string. {self} >= {other}" + other = self.options[other] + return super(Choice, self).__ge__(other) + __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ diff --git a/test/options/test_option_classes.py b/test/options/test_option_classes.py index 8e2c4702c3..ca90db8870 100644 --- a/test/options/test_option_classes.py +++ b/test/options/test_option_classes.py @@ -33,6 +33,15 @@ class TestNumericOptions(unittest.TestCase): self.assertEqual(choice_option_alias, TestChoice.alias_three) self.assertEqual(choice_option_attr, TestChoice.non_option_attr) + self.assertLess(choice_option_string, "two") + self.assertGreater(choice_option_string, "zero") + self.assertLessEqual(choice_option_string, "one") + self.assertLessEqual(choice_option_string, "two") + self.assertGreaterEqual(choice_option_string, "one") + self.assertGreaterEqual(choice_option_string, "zero") + + self.assertGreaterEqual(choice_option_alias, "three") + self.assertRaises(KeyError, TestChoice.from_any, "four") self.assertIn(choice_option_int, [1, 2, 3]) diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 0f96ee94e8..bce9bb5151 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -257,7 +257,7 @@ class WitnessWorld(World): needed_size = 2 needed_size += self.options.puzzle_randomization == "sigma_expert" needed_size += self.options.shuffle_symbols - needed_size += self.options.shuffle_doors > 0 + needed_size += self.options.shuffle_doors != "off" # Then, add checks in order until the required amount of sphere 1 checks is met. diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index c82024cc12..ac5572257f 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -129,7 +129,7 @@ def get_priority_hint_items(world: "WitnessWorld") -> List[str]: "Shadows Laser", ] - if world.options.shuffle_doors >= 2: + if world.options.shuffle_doors >= "doors": priority.add("Desert Laser") priority.update(world.random.sample(lasers, 5)) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 52bddde17e..aed6d3da66 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -435,7 +435,7 @@ class WitnessPlayerLogic: postgame_adjustments = [] # Make some quick references to some options - remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no region accessibility implications. + remote_doors = world.options.shuffle_doors >= "doors" # "Panels" mode has no region accessibility implications. early_caves = world.options.early_caves victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers @@ -592,7 +592,7 @@ class WitnessPlayerLogic: # Make condensed references to some options - remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region access implications. + remote_doors = world.options.shuffle_doors >= "doors" # "Panels" mode has no region access implications. lasers = world.options.shuffle_lasers victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers From f8d1e4edf362b2c3579e1613e318ccc761513a4a Mon Sep 17 00:00:00 2001 From: JKLeckr <11635283+JKLeckr@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:57:19 -0500 Subject: [PATCH 02/29] Ignore .github dir in package test (#5098) Added comments for ignore code --- test/general/test_packages.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/general/test_packages.py b/test/general/test_packages.py index 32c7bdf47e..1df6187ee0 100644 --- a/test/general/test_packages.py +++ b/test/general/test_packages.py @@ -8,7 +8,12 @@ class TestPackages(unittest.TestCase): to indicate full package rather than namespace package.""" import Utils + # Ignore directories with these names. + ignore_dirs = {".github"} + worlds_path = Utils.local_path("worlds") for dirpath, dirnames, filenames in os.walk(worlds_path): + # Drop ignored directories from dirnames, excluding them from walking. + dirnames[:] = [d for d in dirnames if d not in ignore_dirs] with self.subTest(directory=dirpath): self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames)) From 5e2702090c96959fc963aa72170fb861aaf656b1 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Sun, 27 Jul 2025 18:10:06 -0700 Subject: [PATCH 03/29] Tests: only get `__init__.py` tests from test directories (#5135) --- pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index 4469a7c30d..f050d58b70 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported +python_files = test_*.py Test*.py **/test*/**/__init__.py # TODO: remove Test* once all worlds have been ported python_classes = Test python_functions = test testpaths = From 4d1736666269b9be61890f8cd0d3fe4b9633d38d Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Mon, 28 Jul 2025 06:41:43 -0700 Subject: [PATCH 04/29] Core: fix dangerous mutable default in Fill (#5247) discussed here https://discord.com/channels/731205301247803413/731214280439103580/1327712564213448775 --- Fill.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Fill.py b/Fill.py index 29a9a530a4..1cc1278f4b 100644 --- a/Fill.py +++ b/Fill.py @@ -358,7 +358,12 @@ def fast_fill(multiworld: MultiWorld, return item_pool[placing:], fill_locations[placing:] -def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): +def accessibility_corrections(multiworld: MultiWorld, + state: CollectionState, + locations: list[Location], + pool: list[Item] | None = None) -> None: + if pool is None: + pool = [] maximum_exploration_state = sweep_from_pool(state, pool) minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} From ad17c7fd216b9b06fcb86182f9057f7fea2b7a44 Mon Sep 17 00:00:00 2001 From: BlastSlimey <89539656+BlastSlimey@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:01:57 +0200 Subject: [PATCH 05/29] shapez: Typing Cleanup + Small Docs Rewordings (#5189) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/shapez/__init__.py | 22 ++++---- worlds/shapez/common/options.py | 48 +++++++++--------- worlds/shapez/data/generate.py | 13 +++-- worlds/shapez/docs/datapackage_settings_de.md | 9 ++-- worlds/shapez/docs/datapackage_settings_en.md | 28 +++++------ worlds/shapez/docs/de_shapez.md | 20 ++++---- worlds/shapez/docs/en_shapez.md | 34 ++++++------- worlds/shapez/docs/setup_de.md | 7 +-- worlds/shapez/docs/setup_en.md | 6 +-- worlds/shapez/items.py | 36 ++++++------- worlds/shapez/locations.py | 50 +++++++++---------- worlds/shapez/regions.py | 12 ++--- worlds/shapez/test/__init__.py | 2 +- 13 files changed, 144 insertions(+), 143 deletions(-) diff --git a/worlds/shapez/__init__.py b/worlds/shapez/__init__.py index 2a77ed8c9c..5557e2a96a 100644 --- a/worlds/shapez/__init__.py +++ b/worlds/shapez/__init__.py @@ -1,5 +1,5 @@ import math -from typing import Any, List, Dict, Tuple, Mapping +from typing import Mapping, Any from Options import OptionError from .data.strings import OTHER, ITEMS, CATEGORY, LOCATIONS, SLOTDATA, GOALS, OPTIONS @@ -123,23 +123,23 @@ class ShapezWorld(World): # Defining instance attributes for each shapez world # These are set to default values that should fail unit tests if not replaced with correct values self.location_count: int = 0 - self.level_logic: List[str] = [] - self.upgrade_logic: List[str] = [] + self.level_logic: list[str] = [] + self.upgrade_logic: list[str] = [] self.level_logic_type: str = "" self.upgrade_logic_type: str = "" - self.random_logic_phase_length: List[int] = [] - self.category_random_logic_amounts: Dict[str, int] = {} + self.random_logic_phase_length: list[int] = [] + self.category_random_logic_amounts: dict[str, int] = {} self.maxlevel: int = 0 self.finaltier: int = 0 - self.included_locations: Dict[str, Tuple[str, LocationProgressType]] = {} + self.included_locations: dict[str, tuple[str, LocationProgressType]] = {} self.client_seed: int = 0 - self.shapesanity_names: List[str] = [] + self.shapesanity_names: list[str] = [] self.upgrade_traps_allowed: bool = False # Universal Tracker support self.ut_active: bool = False - self.passthrough: Dict[str, any] = {} - self.location_id_to_alias: Dict[int, str] = {} + self.passthrough: dict[str, Any] = {} + self.location_id_to_alias: dict[int, str] = {} @classmethod def stage_generate_early(cls, multiworld: MultiWorld) -> None: @@ -315,7 +315,7 @@ class ShapezWorld(World): def create_items(self) -> None: # Include guaranteed items (game mechanic unlocks and 7x4 big upgrades) - included_items: List[Item] = ([self.create_item(name) for name in buildings_processing.keys()] + included_items: list[Item] = ([self.create_item(name) for name in buildings_processing.keys()] + [self.create_item(name) for name in buildings_routing.keys()] + [self.create_item(name) for name in buildings_other.keys()] + [self.create_item(name) for name in buildings_top_row.keys()] @@ -412,6 +412,6 @@ class ShapezWorld(World): **logic_type_cat_random_data, SLOTDATA.seed: self.client_seed, SLOTDATA.shapesanity: self.shapesanity_names} - def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]: + def interpret_slot_data(self, slot_data: dict[str, Any]) -> dict[str, Any]: """Helper function for Universal Tracker""" return slot_data diff --git a/worlds/shapez/common/options.py b/worlds/shapez/common/options.py index aa66ced032..8a55448cd4 100644 --- a/worlds/shapez/common/options.py +++ b/worlds/shapez/common/options.py @@ -1,5 +1,5 @@ import random -import typing +from typing import cast, Any from Options import FreeText, NumericOption @@ -47,7 +47,7 @@ class FloatRangeText(FreeText, NumericOption): raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}") @classmethod - def from_text(cls, text: str) -> typing.Any: + def from_text(cls, text: str) -> Any: return cls(text) @classmethod @@ -99,31 +99,31 @@ class FloatRangeText(FreeText, NumericOption): def get_option_name(cls, value: float) -> str: return str(value) - def __eq__(self, other: typing.Any): + def __eq__(self, other: Any): if isinstance(other, NumericOption): return self.value == other.value else: - return typing.cast(bool, self.value == other) + return cast(bool, self.value == other) - def __lt__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __lt__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value < other.value else: return self.value < other - def __le__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __le__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value <= other.value else: return self.value <= other - def __gt__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __gt__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value > other.value else: return self.value > other - def __ge__(self, other: typing.Union[int, float, NumericOption]) -> bool: + def __ge__(self, other: int | float | NumericOption) -> bool: if isinstance(other, NumericOption): return self.value >= other.value else: @@ -132,59 +132,59 @@ class FloatRangeText(FreeText, NumericOption): def __int__(self) -> int: return int(self.value) - def __and__(self, other: typing.Any) -> int: + def __and__(self, other: Any) -> int: raise TypeError("& operator not supported for float values") - def __floordiv__(self, other: typing.Any) -> int: + def __floordiv__(self, other: Any) -> int: return int(self.value // float(other)) def __invert__(self) -> int: raise TypeError("~ operator not supported for float values") - def __lshift__(self, other: typing.Any) -> int: + def __lshift__(self, other: Any) -> int: raise TypeError("<< operator not supported for float values") - def __mod__(self, other: typing.Any) -> float: + def __mod__(self, other: Any) -> float: return self.value % float(other) def __neg__(self) -> float: return -self.value - def __or__(self, other: typing.Any) -> int: + def __or__(self, other: Any) -> int: raise TypeError("| operator not supported for float values") def __pos__(self) -> float: return +self.value - def __rand__(self, other: typing.Any) -> int: + def __rand__(self, other: Any) -> int: raise TypeError("& operator not supported for float values") - def __rfloordiv__(self, other: typing.Any) -> int: + def __rfloordiv__(self, other: Any) -> int: return int(float(other) // self.value) - def __rlshift__(self, other: typing.Any) -> int: + def __rlshift__(self, other: Any) -> int: raise TypeError("<< operator not supported for float values") - def __rmod__(self, other: typing.Any) -> float: + def __rmod__(self, other: Any) -> float: return float(other) % self.value - def __ror__(self, other: typing.Any) -> int: + def __ror__(self, other: Any) -> int: raise TypeError("| operator not supported for float values") - def __round__(self, ndigits: typing.Optional[int] = None) -> float: + def __round__(self, ndigits: int | None = None) -> float: return round(self.value, ndigits) - def __rpow__(self, base: typing.Any) -> typing.Any: + def __rpow__(self, base: Any) -> Any: return base ** self.value - def __rrshift__(self, other: typing.Any) -> int: + def __rrshift__(self, other: Any) -> int: raise TypeError(">> operator not supported for float values") - def __rshift__(self, other: typing.Any) -> int: + def __rshift__(self, other: Any) -> int: raise TypeError(">> operator not supported for float values") - def __rxor__(self, other: typing.Any) -> int: + def __rxor__(self, other: Any) -> int: raise TypeError("^ operator not supported for float values") - def __xor__(self, other: typing.Any) -> int: + def __xor__(self, other: Any) -> int: raise TypeError("^ operator not supported for float values") diff --git a/worlds/shapez/data/generate.py b/worlds/shapez/data/generate.py index 27d74e865d..86b660ef5b 100644 --- a/worlds/shapez/data/generate.py +++ b/worlds/shapez/data/generate.py @@ -1,14 +1,13 @@ import itertools import time -from typing import Dict, List from worlds.shapez.data.strings import SHAPESANITY, REGIONS -shapesanity_simple: Dict[str, str] = {} -shapesanity_1_4: Dict[str, str] = {} -shapesanity_two_sided: Dict[str, str] = {} -shapesanity_three_parts: Dict[str, str] = {} -shapesanity_four_parts: Dict[str, str] = {} +shapesanity_simple: dict[str, str] = {} +shapesanity_1_4: dict[str, str] = {} +shapesanity_two_sided: dict[str, str] = {} +shapesanity_three_parts: dict[str, str] = {} +shapesanity_four_parts: dict[str, str] = {} subshape_names = [SHAPESANITY.circle, SHAPESANITY.square, SHAPESANITY.star, SHAPESANITY.windmill] color_names = [SHAPESANITY.red, SHAPESANITY.blue, SHAPESANITY.green, SHAPESANITY.yellow, SHAPESANITY.purple, SHAPESANITY.cyan, SHAPESANITY.white, SHAPESANITY.uncolored] @@ -16,7 +15,7 @@ short_subshapes = ["C", "R", "S", "W"] short_colors = ["b", "c", "g", "p", "r", "u", "w", "y"] -def color_to_needed_building(color_list: List[str]) -> str: +def color_to_needed_building(color_list: list[str]) -> str: for next_color in color_list: if next_color in [SHAPESANITY.yellow, SHAPESANITY.purple, SHAPESANITY.cyan, SHAPESANITY.white, "y", "p", "c", "w"]: diff --git a/worlds/shapez/docs/datapackage_settings_de.md b/worlds/shapez/docs/datapackage_settings_de.md index ae375f3e3c..a6c1b35cb8 100644 --- a/worlds/shapez/docs/datapackage_settings_de.md +++ b/worlds/shapez/docs/datapackage_settings_de.md @@ -4,7 +4,7 @@ Die Maximalwerte von `goal_amount` und `shapesanity_amount` sind fest eingebaute Einstellungen, die das Datenpaket des Spiels beeinflussen. Sie sind in einer Datei names `options.json` innerhalb der APWorld festgelegt. Durch das Ändern -dieser Werte erschaffst du eine custom APWorld, die nur auf deinem PC existiert. +dieser Werte erschaffst du eine custom Version der APWorld, die nur auf deinem PC existiert. ## Wie du die Datenpaket-Einstellungen änderst @@ -18,17 +18,18 @@ ordnungsgemäß befolgt wird. Anwendung auf eigene Gefahr. - `max_shapesanity` kann nicht weniger als `4` sein, da dies die benötigte Mindestanzahl zum Verhindern von FillErrors ist. - `max_shapesanity` kann auch nicht mehr als `75800` sein, da dies die maximale Anzahl an möglichen Shapesanity-Namen - ist. Ansonsten könnte die Generierung der Multiworld fehlschlagen. + ist. Das Generieren der Multiworld wird fehlschlagen, falls die `shapesanity_amount`-Option auf einen höheren Wert + gesetzt wird. - `max_levels_and_upgrades` kann nicht weniger als `27` sein, da dies die Mindestanzahl für das `mam`-Ziel ist. 5. Schließe die Zip-Datei und benenne sie zurück zu `shapez.apworld`. ## Warum muss ich das ganze selbst machen? Alle Spiele in Archipelago müssen eine Liste aller möglichen Locations **unabhängig der Spieler-Optionen** -bereitstellen. Diese Listen aller in einer Multiworld inkludierten Spiele werden in den Daten der Multiworld gespeichert +bereitstellen. Diese Listen aller in einer Multiworld inkludierten Spiele werden in den Daten der Multiworld gespeichert und an alle verbundenen Clients gesendet. Je mehr mögliche Locations, desto größer das Datenpaket. Und mit ~80000 möglichen Locations hatte shapez zu einem gewissen Zeitpunkt ein (von der Datenmenge her) größeres Datenpaket als alle -supporteten Spiele zusammen. Um also diese Datenmenge zu reduzieren wurden die ausgeschriebenen +Core-verifizierten Spiele zusammen. Um also diese Datenmenge zu reduzieren, wurden die ausgeschriebenen Shapesanity-Locations-Namen (`Shapesanity Uncolored Circle`, `Shapesanity Blue Rectangle`, ...) durch standardisierte Namen (`Shapesanity 1`, `Shapesanity 2`, ...) ersetzt. Durch das Ändern dieser Maximalwerte, und damit das Erstellen einer custom APWorld, kannst du die Anzahl der möglichen Locations erhöhen, wirst aber auch gleichzeitig das Datenpaket diff --git a/worlds/shapez/docs/datapackage_settings_en.md b/worlds/shapez/docs/datapackage_settings_en.md index fd0ed1673d..64f39abf2e 100644 --- a/worlds/shapez/docs/datapackage_settings_en.md +++ b/worlds/shapez/docs/datapackage_settings_en.md @@ -1,14 +1,14 @@ -# Guide to change maximum locations in shapez +# Guide to change the maximum amount of locations in shapez ## Where do I find the settings to increase/decrease the amount of possible locations? -The maximum values of the `goal_amount` and `shapesanity_amount` are hardcoded settings that affect the datapackage. -They are stored in a file called `options.json` inside the apworld. By changing them, you will create a custom apworld -on your local machine. +The maximum values of the `goal_amount` and `shapesanity_amount` options are hardcoded settings that affect the +datapackage. They are stored in a file called `options.json` inside the apworld. By changing them, you will create a +custom version on your local machine. -## How to change datapackage options +## How to change datapackage settings -This tutorial is for advanced users and can result in the software not working properly, if not read carefully. +This tutorial is intended for advanced users and can result in the software not working properly, if not read carefully. Proceed at your own risk. 1. Go to `/lib/worlds`. @@ -17,17 +17,17 @@ Proceed at your own risk. 4. Edit the values in this file to your desire and save the file. - `max_shapesanity` cannot be lower than `4`, as this is the minimum amount to prevent FillErrors. - `max_shapesanity` also cannot be higher than `75800`, as this is the maximum amount of possible shapesanity names. - Else the multiworld generation might fail. + Multiworld generation will fail if the `shapesanity_amount` options is set to a higher value. - `max_levels_and_upgrades` cannot be lower than `27`, as this is the minimum amount for the `mam` goal to properly work. -5. Close the zip and rename it back to `shapez.apworld`. +5. Close the zip file and rename it back to `shapez.apworld`. ## Why do I have to do this manually? For every game in Archipelago, there must be a list of all possible locations, **regardless of player options**. When -generating a multiworld, a list of all locations of all included games will be saved in the multiworld data and sent to -all clients. The higher the amount of possible locations, the bigger the datapackage. And having ~80000 possible -locations at one point made the datapackage for shapez bigger than all other supported games combined. So to reduce the -datapackage of shapez, the locations for shapesanity are named `Shapesanity 1`, `Shapesanity 2` etc. instead of their -actual names. By creating a custom apworld, you can increase the amount of possible locations, but you will also -increase the size of the datapackage at the same time. +generating a multiworld, a list of all locations of all included games will be saved in the multiworld's data and sent +to all clients. The higher the amount of possible locations, the bigger the datapackage. And having ~80000 possible +locations at one point made the datapackage for shapez bigger than all other core-verified games combined. So, to reduce +the datapackage size of shapez, the locations for shapesanity are named `Shapesanity 1`, `Shapesanity 2` etc. instead of +their actual names. By creating a custom version of the apworld, you can increase the amount of possible locations, but +you will also increase the size of the datapackage at the same time. diff --git a/worlds/shapez/docs/de_shapez.md b/worlds/shapez/docs/de_shapez.md index 4a26ea821c..494edca210 100644 --- a/worlds/shapez/docs/de_shapez.md +++ b/worlds/shapez/docs/de_shapez.md @@ -19,25 +19,27 @@ Zusätzlich gibt es zu diesem Spiel "Datenpaket-Einstellungen", die du nach Alle Belohnungen aus den Tutorial-Level (das Freischalten von Gebäuden und Spielmechaniken) und Verbesserungen durch Upgrades werden dem Itempool der Multiworld hinzugefügt. Außerdem werden, wenn so in den Spieler-Optionen festgelegt, -die Bedingungen zum Abschließen eines Levels und zum Kaufen der Upgrades randomisiert. +die Bedingungen zum Abschließen eines Levels und zum Kaufen der Upgrades randomisiert und die Reihenfolge der Gebäude +in deinen Toolbars (Haupt- und Kabelebene) gemischt. ## Was ist das Ziel von shapez in Archipelago? -Da das Spiel eigentlich kein konkretes Ziel (nach dem Tutorial) hat, kann man sich zwischen (momentan) 4 verschiedenen -Zielen entscheiden: +Da das Spiel eigentlich kein konkretes Ziel, welches das Ende des Spiels bedeuten würde, hat, kann man sich zwischen +(aktuell) 4 verschiedenen Zielen entscheiden: 1. Vanilla: Schließe Level 26 ab (eigentlich das Ende des Tutorials). 2. MAM: Schließe ein bestimmtes Level nach Level 26 ab, das zuvor in den Spieler-Optionen festgelegt wurde. Es ist empfohlen, eine Maschine zu bauen, die alles automatisch herstellt ("Make-Anything-Machine", kurz MAM). -3. Even Fasterer: Kaufe alle Upgrades bis zu einer in den Spieler-Optionen festgelegten Stufe (nach Stufe 8). +3. Even Fasterer: Kaufe alle Upgrades bis zu einer in den Spieler-Optionen festgelegten Stufe (nach Stufe VIII (8)). 4. Efficiency III: Liefere 256 Blaupausen-Formen pro Sekunde ins Zentrum. ## Welche Items können in den Welten anderer Spieler erscheinen? -- Freischalten verschiedener Gebäude +- Gebäude - Blaupausen freischalten -- Große Upgrades (addiert 1 zum Geschwindigkeitsmultiplikator) -- Kleine Upgrades (addiert 0.1 zum Geschwindigkeitsmultiplikator) -- Andere ungewöhnliche Upgrades (optional) +- Upgrades + - Große Upgrades (addiert 1 zum Geschwindigkeitsmultiplikator) + - Kleine Upgrades (addiert 0.1 zum Geschwindigkeitsmultiplikator) + - Andere ungewöhnliche (auch negative) Upgrades (optional) - Verschiedene Bündel, die bestimmte Formen enthalten - Fallen, die bestimmte Formen aus dem Zentrum dränieren (ja, das Wort gibt es) - Fallen, die zufällige Gebäude oder andere Spielmechaniken betreffen @@ -45,7 +47,7 @@ empfohlen, eine Maschine zu bauen, die alles automatisch herstellt ("Make-Anythi ## Was ist eine Location / ein Check? - Level (minimum 1-25, bis zu 499 je nach Spieler-Optionen, mit zusätzlichen Checks für Level 1 und 20) -- Upgrades (minimum Stufen II-VIII (2-8), bis zu D (500) je nach Spieler-Optionen) +- Upgrades (minimum Stufen II-VIII (2-8), bis zu D (500), je nach Spieler-Optionen) - Bestimmte Formen mindestens einmal ins Zentrum liefern ("Shapesanity", bis zu 1000 zufällig gewählte Definitionen) - Errungenschaften (bis zu 45) diff --git a/worlds/shapez/docs/en_shapez.md b/worlds/shapez/docs/en_shapez.md index dc41d73d7e..56c0387258 100644 --- a/worlds/shapez/docs/en_shapez.md +++ b/worlds/shapez/docs/en_shapez.md @@ -4,9 +4,9 @@ shapez is an automation game about cutting, rotating, stacking, and painting shapes, that you extract from randomly generated patches on an infinite canvas, and sending them to the hub to complete levels. The "tutorial", where you -unlock a new building or game mechanic (almost) each level, lasts until level 26, where you unlock freeplay with -infinitely more levels, that require a new, randomly generated shape. Alongside the levels, you can unlock upgrades, -that make your buildings work faster. +unlock a new building or game mechanic (almost) each level, lasts until level 26, which unlocks freeplay with +infinitely more levels, that each require a new, randomly generated shape. Alongside the levels, you can unlock +upgrades, that make your buildings work faster. ## Where is the options page? @@ -17,29 +17,30 @@ There are also some advanced "datapackage settings" that can be changed by follo ## What does randomization do to this game? -Buildings and gameplay mechanics, that you normally unlock by completing a level, and upgrade improvements are put -into the item pool of the multiworld. Also, if enabled, the requirements for completing a level or buying an upgrade are -randomized. +Buildings and gameplay mechanics, which you normally unlock by completing a level, and upgrade improvements are put +into the item pool of the multiworld. You can also randomize the requirements for completing a level or buying an +upgrade and shuffle the order of building in your toolbars (main and wires layer). ## What is the goal of shapez in Archipelago? -As the game has no actual goal where the game ends, there are (currently) 4 different goals you can choose from in the -player options: +As the game has no actual goal that would represent the end of the game, there are (currently) 4 different goals you +can choose from in the player options: 1. Vanilla: Complete level 26 (the end of the tutorial). 2. MAM: Complete a player-specified level after level 26. It's recommended to build a Make-Anything-Machine (MAM). -3. Even Fasterer: Upgrade everything to a player-specified tier after tier 8. +3. Even Fasterer: Upgrade everything to a player-specified tier after tier VIII (8). 4. Efficiency III: Deliver 256 blueprint shapes per second to the hub. ## Which items can be in another player's world? -- Unlock different buildings -- Unlock blueprints -- Big upgrade improvements (adds 1 to the multiplier) -- Small upgrade improvements (adds .1 to the multiplier) -- Other unusual upgrade improvements (optional) +- Buildings +- Unlocking blueprints +- Upgrade improvements + - Big improvements, adding 1 to the multiplier + - Small improvements, adding 0.1 to the multiplier + - Optional: Other, rather unusual and even bad, improvements - Different shapes bundles - Inventory draining traps -- Different traps afflicting random buildings and game mechanics +- Different traps affecting random buildings and game mechanics ## What is considered a location check? @@ -61,5 +62,4 @@ Here's a cheat sheet: ## Can I use other mods alongside the AP client? At the moment, compatibility with other mods is not supported, but not forbidden. Gameplay altering mods will most -likely crash the game or disable loading the afflicted mods, while QoL mods might work without problems. Try at your own -risk. +likely break the game in some way, while small QoL mods might work without problems. Try at your own risk. diff --git a/worlds/shapez/docs/setup_de.md b/worlds/shapez/docs/setup_de.md index 1b927f3790..a2eb92dfa1 100644 --- a/worlds/shapez/docs/setup_de.md +++ b/worlds/shapez/docs/setup_de.md @@ -16,9 +16,10 @@ - Archipelago von der [Archipelago-Release-Seite](https://github.com/ArchipelagoMW/Archipelago/releases) * (Für den Text-Client) - * (Alternativ kannst du auch die eingebaute Konsole (nur lesbar) nutzen, indem du beim Starten des Spiels den - `-dev`-Parameter verwendest) -- Universal Tracker (schau im `#future-game-design`-Thread für UT auf dem Discord-Server nach der aktuellen Anleitung) + * (Alternativ kannst du auch die eingebaute Konsole nutzen, indem du das Spiel mit dem `-dev`-Parameter + startest und jede Nachricht als `AP.sendAPMessage(""")` schreibst) +- Universal Tracker (schau im Kanal von UT auf dem Discord-Server nach der aktuellen Anleitung und für weitere + Informationen) ## Installation diff --git a/worlds/shapez/docs/setup_en.md b/worlds/shapez/docs/setup_en.md index 4c91c16a0b..2036f75d6c 100644 --- a/worlds/shapez/docs/setup_en.md +++ b/worlds/shapez/docs/setup_en.md @@ -16,9 +16,9 @@ - Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) * (Only for the TextClient) - * (If you want, you can use the built-in console as a read-only text client by launching the game - with the `-dev` parameter) -- Universal Tracker (check UT's `#future-game-design` thread in the discord server for instructions) + * (You can alternatively use the built-in console by launching the game with the `-dev` parameter and typing + `AP.sendAPMessage(""")`) +- Universal Tracker (check UT's channel in the discord server for more information and instructions) ## Installation diff --git a/worlds/shapez/items.py b/worlds/shapez/items.py index aef4c03317..2e5816b9fc 100644 --- a/worlds/shapez/items.py +++ b/worlds/shapez/items.py @@ -1,4 +1,4 @@ -from typing import Dict, Callable, Any, List +from typing import Callable, Any from BaseClasses import Item, ItemClassification as IClass from .options import ShapezOptions @@ -37,7 +37,7 @@ def always_trap(options: ShapezOptions) -> IClass: # would be unreasonably complicated and time-consuming. # Some buildings are not needed to complete the game, but are "logically needed" for the "MAM" achievement. -buildings_processing: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_processing: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.cutter: always_progression, ITEMS.cutter_quad: always_progression, ITEMS.rotator: always_progression, @@ -50,7 +50,7 @@ buildings_processing: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.color_mixer: always_progression, } -buildings_routing: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_routing: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.balancer: always_progression, ITEMS.comp_merger: always_progression, ITEMS.comp_splitter: always_progression, @@ -58,12 +58,12 @@ buildings_routing: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.tunnel_tier_ii: is_mam_achievement_included, } -buildings_other: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_other: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trash: always_progression, ITEMS.extractor_chain: always_useful } -buildings_top_row: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_top_row: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.belt_reader: is_mam_achievement_included, ITEMS.storage: is_achievements_included, ITEMS.switch: always_progression, @@ -71,18 +71,18 @@ buildings_top_row: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.display: always_useful } -buildings_wires: Dict[str, Callable[[ShapezOptions], IClass]] = { +buildings_wires: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.wires: always_progression, ITEMS.const_signal: always_progression, ITEMS.logic_gates: is_mam_achievement_included, ITEMS.virtual_proc: is_mam_achievement_included } -gameplay_unlocks: Dict[str, Callable[[ShapezOptions], IClass]] = { +gameplay_unlocks: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.blueprints: is_achievements_included } -upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { +upgrades: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_big_belt: always_progression, ITEMS.upgrade_big_miner: always_useful, ITEMS.upgrade_big_proc: always_useful, @@ -93,7 +93,7 @@ upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_small_paint: always_filler } -whacky_upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { +whacky_upgrades: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_gigantic_belt: always_progression, ITEMS.upgrade_gigantic_miner: always_useful, ITEMS.upgrade_gigantic_proc: always_useful, @@ -106,7 +106,7 @@ whacky_upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.upgrade_small_random: always_filler, } -whacky_upgrade_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { +whacky_upgrade_traps: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_upgrade_belt: always_trap, ITEMS.trap_upgrade_miner: always_trap, ITEMS.trap_upgrade_proc: always_trap, @@ -117,13 +117,13 @@ whacky_upgrade_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_upgrade_demonic_paint: always_trap, } -bundles: Dict[str, Callable[[ShapezOptions], IClass]] = { +bundles: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.bundle_blueprint: always_filler, ITEMS.bundle_level: always_filler, ITEMS.bundle_upgrade: always_filler } -standard_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { +standard_traps: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_locked: always_trap, ITEMS.trap_throttled: always_trap, ITEMS.trap_malfunction: always_trap, @@ -131,22 +131,22 @@ standard_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_clear_belts: always_trap, } -random_draining_trap: Dict[str, Callable[[ShapezOptions], IClass]] = { +random_draining_trap: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_draining_inv: always_trap } -split_draining_traps: Dict[str, Callable[[ShapezOptions], IClass]] = { +split_draining_traps: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.trap_draining_blueprint: always_trap, ITEMS.trap_draining_level: always_trap, ITEMS.trap_draining_upgrade: always_trap } -belt_and_extractor: Dict[str, Callable[[ShapezOptions], IClass]] = { +belt_and_extractor: dict[str, Callable[[ShapezOptions], IClass]] = { ITEMS.belt: always_progression, ITEMS.extractor: always_progression } -item_table: Dict[str, Callable[[ShapezOptions], IClass]] = { +item_table: dict[str, Callable[[ShapezOptions], IClass]] = { **buildings_processing, **buildings_routing, **buildings_other, @@ -205,10 +205,10 @@ def trap(random: float, split_draining: bool, whacky_allowed: bool) -> str: return random_choice_nested(random, pool) -def random_choice_nested(random: float, nested: List[Any]) -> Any: +def random_choice_nested(random: float, nested: list[Any]) -> Any: """Helper function for getting a random element from a nested list.""" current: Any = nested - while isinstance(current, List): + while isinstance(current, list): index_float = random*len(current) current = current[int(index_float)] random = index_float-int(index_float) diff --git a/worlds/shapez/locations.py b/worlds/shapez/locations.py index 6d069afaa8..f68ca1ebf5 100644 --- a/worlds/shapez/locations.py +++ b/worlds/shapez/locations.py @@ -1,5 +1,5 @@ from random import Random -from typing import List, Tuple, Dict, Optional, Callable +from typing import Callable from BaseClasses import Location, LocationProgressType, Region from .data.strings import CATEGORY, LOCATIONS, REGIONS, OPTIONS, GOALS, OTHER, SHAPESANITY @@ -7,7 +7,7 @@ from .options import max_shapesanity, max_levels_and_upgrades categories = [CATEGORY.belt, CATEGORY.miner, CATEGORY.processors, CATEGORY.painting] -translate: List[Tuple[int, str]] = [ +translate: list[tuple[int, str]] = [ (1000, "M"), (900, "CM"), (500, "D"), @@ -148,17 +148,17 @@ location_description = { # TODO change keys to global strings "windmill.", } -shapesanity_simple: Dict[str, str] = {} -shapesanity_1_4: Dict[str, str] = {} -shapesanity_two_sided: Dict[str, str] = {} -shapesanity_three_parts: Dict[str, str] = {} -shapesanity_four_parts: Dict[str, str] = {} +shapesanity_simple: dict[str, str] = {} +shapesanity_1_4: dict[str, str] = {} +shapesanity_two_sided: dict[str, str] = {} +shapesanity_three_parts: dict[str, str] = {} +shapesanity_four_parts: dict[str, str] = {} -level_locations: List[str] = ([LOCATIONS.level(1, 1), LOCATIONS.level(20, 1), LOCATIONS.level(20, 2)] +level_locations: list[str] = ([LOCATIONS.level(1, 1), LOCATIONS.level(20, 1), LOCATIONS.level(20, 2)] + [LOCATIONS.level(x) for x in range(1, max_levels_and_upgrades)]) -upgrade_locations: List[str] = [LOCATIONS.upgrade(cat, roman(x)) +upgrade_locations: list[str] = [LOCATIONS.upgrade(cat, roman(x)) for cat in categories for x in range(2, max_levels_and_upgrades+1)] -achievement_locations: List[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATIONS.cutter, LOCATIONS.rotater, +achievement_locations: list[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATIONS.cutter, LOCATIONS.rotater, LOCATIONS.wait_they_stack, LOCATIONS.wires, LOCATIONS.storage, LOCATIONS.freedom, LOCATIONS.the_logo, LOCATIONS.to_the_moon, LOCATIONS.its_piling_up, LOCATIONS.use_it_later, LOCATIONS.efficiency_1, LOCATIONS.preparing_to_launch, @@ -172,7 +172,7 @@ achievement_locations: List[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATI LOCATIONS.mam, LOCATIONS.perfectionist, LOCATIONS.next_dimension, LOCATIONS.oops, LOCATIONS.copy_pasta, LOCATIONS.ive_seen_that_before, LOCATIONS.memories, LOCATIONS.i_need_trains, LOCATIONS.a_bit_early, LOCATIONS.gps] -shapesanity_locations: List[str] = [LOCATIONS.shapesanity(x) for x in range(1, max_shapesanity+1)] +shapesanity_locations: list[str] = [LOCATIONS.shapesanity(x) for x in range(1, max_shapesanity+1)] def init_shapesanity_pool() -> None: @@ -186,12 +186,12 @@ def init_shapesanity_pool() -> None: def addlevels(maxlevel: int, logictype: str, - random_logic_phase_length: List[int]) -> Dict[str, Tuple[str, LocationProgressType]]: + random_logic_phase_length: list[int]) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with all level locations based on player options (maxlevel INCLUDED). If shape requirements are not randomized, the logic type is expected to be vanilla.""" # Level 1 is always directly accessible - locations: Dict[str, Tuple[str, LocationProgressType]] \ + locations: dict[str, tuple[str, LocationProgressType]] \ = {LOCATIONS.level(1): (REGIONS.main, LocationProgressType.PRIORITY), LOCATIONS.level(1, 1): (REGIONS.main, LocationProgressType.PRIORITY)} level_regions = [REGIONS.main, REGIONS.levels_1, REGIONS.levels_2, REGIONS.levels_3, @@ -282,11 +282,11 @@ def addlevels(maxlevel: int, logictype: str, def addupgrades(finaltier: int, logictype: str, - category_random_logic_amounts: Dict[str, int]) -> Dict[str, Tuple[str, LocationProgressType]]: + category_random_logic_amounts: dict[str, int]) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with all upgrade locations based on player options (finaltier INCLUDED). If shape requirements are not randomized, give logic type 0.""" - locations: Dict[str, Tuple[str, LocationProgressType]] = {} + locations: dict[str, tuple[str, LocationProgressType]] = {} upgrade_regions = [REGIONS.main, REGIONS.upgrades_1, REGIONS.upgrades_2, REGIONS.upgrades_3, REGIONS.upgrades_4, REGIONS.upgrades_5] @@ -366,13 +366,13 @@ def addupgrades(finaltier: int, logictype: str, def addachievements(excludesoftlock: bool, excludelong: bool, excludeprogressive: bool, - maxlevel: int, upgradelogictype: str, category_random_logic_amounts: Dict[str, int], - goal: str, presentlocations: Dict[str, Tuple[str, LocationProgressType]], + maxlevel: int, upgradelogictype: str, category_random_logic_amounts: dict[str, int], + goal: str, presentlocations: dict[str, tuple[str, LocationProgressType]], add_alias: Callable[[str, str], None], has_upgrade_traps: bool - ) -> Dict[str, Tuple[str, LocationProgressType]]: + ) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with all achievement locations based on player options.""" - locations: Dict[str, Tuple[str, LocationProgressType]] = dict() + locations: dict[str, tuple[str, LocationProgressType]] = dict() upgrade_regions = [REGIONS.main, REGIONS.upgrades_1, REGIONS.upgrades_2, REGIONS.upgrades_3, REGIONS.upgrades_4, REGIONS.upgrades_5] @@ -472,10 +472,10 @@ def addachievements(excludesoftlock: bool, excludelong: bool, excludeprogressive def addshapesanity(amount: int, random: Random, append_shapesanity: Callable[[str], None], - add_alias: Callable[[str, str], None]) -> Dict[str, Tuple[str, LocationProgressType]]: + add_alias: Callable[[str, str], None]) -> dict[str, tuple[str, LocationProgressType]]: """Returns a dictionary with a given number of random shapesanity locations.""" - included_shapes: Dict[str, Tuple[str, LocationProgressType]] = {} + included_shapes: dict[str, tuple[str, LocationProgressType]] = {} def f(name: str, region: str, alias: str, progress: LocationProgressType = LocationProgressType.DEFAULT) -> None: included_shapes[name] = (region, progress) @@ -518,11 +518,11 @@ def addshapesanity(amount: int, random: Random, append_shapesanity: Callable[[st return included_shapes -def addshapesanity_ut(shapesanity_names: List[str], add_alias: Callable[[str, str], None] - ) -> Dict[str, Tuple[str, LocationProgressType]]: +def addshapesanity_ut(shapesanity_names: list[str], add_alias: Callable[[str, str], None] + ) -> dict[str, tuple[str, LocationProgressType]]: """Returns the same information as addshapesanity but will add specific values based on a UT rebuild.""" - included_shapes: Dict[str, Tuple[str, LocationProgressType]] = {} + included_shapes: dict[str, tuple[str, LocationProgressType]] = {} for name in shapesanity_names: for options in [shapesanity_simple, shapesanity_1_4, shapesanity_two_sided, shapesanity_three_parts, @@ -540,7 +540,7 @@ def addshapesanity_ut(shapesanity_names: List[str], add_alias: Callable[[str, st class ShapezLocation(Location): game = OTHER.game_name - def __init__(self, player: int, name: str, address: Optional[int], region: Region, + def __init__(self, player: int, name: str, address: int | None, region: Region, progress_type: LocationProgressType): super(ShapezLocation, self).__init__(player, name, address, region) self.progress_type = progress_type diff --git a/worlds/shapez/regions.py b/worlds/shapez/regions.py index c4ca1d0c81..b5835461d8 100644 --- a/worlds/shapez/regions.py +++ b/worlds/shapez/regions.py @@ -1,5 +1,3 @@ -from typing import Dict, Tuple, List - from BaseClasses import Region, MultiWorld, LocationProgressType, ItemClassification, CollectionState from .items import ShapezItem from .locations import ShapezLocation @@ -102,7 +100,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) -> return multiplier >= needed -def has_logic_list_building(state: CollectionState, player: int, buildings: List[str], index: int, +def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int, includeuseful: bool) -> bool: # Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres @@ -126,11 +124,11 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: List def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool, - included_locations: Dict[str, Tuple[str, LocationProgressType]], - location_name_to_id: Dict[str, int], level_logic_buildings: List[str], - upgrade_logic_buildings: List[str], early_useful: str, goal: str) -> List[Region]: + included_locations: dict[str, tuple[str, LocationProgressType]], + location_name_to_id: dict[str, int], level_logic_buildings: list[str], + upgrade_logic_buildings: list[str], early_useful: str, goal: str) -> list[Region]: """Creates and returns a list of all regions with entrances and all locations placed correctly.""" - regions: Dict[str, Region] = {name: Region(name, player, multiworld) for name in all_regions} + regions: dict[str, Region] = {name: Region(name, player, multiworld) for name in all_regions} # Creates ShapezLocations for every included location and puts them into the correct region for name, data in included_locations.items(): diff --git a/worlds/shapez/test/__init__.py b/worlds/shapez/test/__init__.py index d2dfad97da..c8855be960 100644 --- a/worlds/shapez/test/__init__.py +++ b/worlds/shapez/test/__init__.py @@ -1,7 +1,7 @@ from unittest import TestCase from test.bases import WorldTestBase -from .. import options_presets, ShapezWorld +from .. import ShapezWorld from ..data.strings import GOALS, OTHER, ITEMS, LOCATIONS, CATEGORY, OPTIONS, SHAPESANITY from ..options import max_levels_and_upgrades, max_shapesanity From 2a0ed7faa2c38e47db9d04be4d56835cd9c355cb Mon Sep 17 00:00:00 2001 From: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> Date: Tue, 29 Jul 2025 19:18:34 -0400 Subject: [PATCH 06/29] LttP: Remove per_slot_randoms in LttPAdjuster.py (#4898) --- LttPAdjuster.py | 1 - 1 file changed, 1 deletion(-) diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 963557e8da..d44f413499 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -40,7 +40,6 @@ class AdjusterWorld(object): def __init__(self, sprite_pool): import random self.sprite_pool = {1: sprite_pool} - self.per_slot_randoms = {1: random} self.worlds = {1: self.AdjusterSubWorld(random)} From 1d8a0b294055d991ba28c4a29055d44d302ab5cf Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Wed, 30 Jul 2025 03:10:36 +0100 Subject: [PATCH 07/29] SM: Speed up deepcopy in copy_mixin (#4228) --- worlds/sm/variaRandomizer/logic/smbool.py | 8 ++++ .../sm/variaRandomizer/logic/smboolmanager.py | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/worlds/sm/variaRandomizer/logic/smbool.py b/worlds/sm/variaRandomizer/logic/smbool.py index b7f596cbbb..25f20b550a 100644 --- a/worlds/sm/variaRandomizer/logic/smbool.py +++ b/worlds/sm/variaRandomizer/logic/smbool.py @@ -66,6 +66,14 @@ class SMBool: def __copy__(self): return SMBool(self.bool, self.difficulty, self._knows, self._items) + def __deepcopy__(self, memodict): + # `bool` and `difficulty` are a `bool` and `int`, so do not need to be copied. + # The `_knows` list is never mutated, so does not need to be copied. + # The `_items` list is a `list[str | list[str]]` (copied to a flat `list[str]` when accessed through the `items` + # property) that is mutated by code in helpers.py, so needs to be copied. Because there could be lists within + # the list, it is copied using the `flatten()` helper function. + return SMBool(self.bool, self.difficulty, self._knows, flatten(self._items)) + def json(self): # as we have slots instead of dict return {'bool': self.bool, 'difficulty': self.difficulty, 'knows': self.knows, 'items': self.items} diff --git a/worlds/sm/variaRandomizer/logic/smboolmanager.py b/worlds/sm/variaRandomizer/logic/smboolmanager.py index 16f903074e..27abb0d31d 100644 --- a/worlds/sm/variaRandomizer/logic/smboolmanager.py +++ b/worlds/sm/variaRandomizer/logic/smboolmanager.py @@ -8,6 +8,7 @@ from ..utils.doorsmanager import DoorsManager from ..utils.objectives import Objectives from ..utils.parameters import Knows, isKnows import logging +from copy import deepcopy import sys class SMBoolManager(object): @@ -34,6 +35,46 @@ class SMBoolManager(object): self.createFacadeFunctions() self.createKnowsFunctions(player) self.resetItems() + self.itemsPositions = {} + + def __deepcopy__(self, memodict): + # Use __new__ to avoid calling __init__ like copy.deepcopy without __deepcopy__ implemented. + new = object.__new__(type(self)) + + # Copy everything over in the same order as __init__, ensuring that mutable attributes are deeply copied. + + # SMBool instances contain mutable lists, so must be deep-copied. + new._items = {i: deepcopy(v, memodict) for i, v in self._items.items()} + # `_counts` is a dict[str, int], so the dict can be copied because its keys and values are immutable. + new._counts = self._counts.copy() + # `player` is an int. + new.player = self.player + # `maxDiff` is an int. + new.maxDiff = self.maxDiff + # `onlyBossLeft` is a bool. + new.onlyBossLeft = self.onlyBossLeft + # The HelpersGraph keeps reference to the instance, so a new HelpersGraph is required. + new.helpers = Logic.HelpersGraph(new) + # DoorsManager is stateless, so the same instance can be used. + new.doorsManager = self.doorsManager + # Objectives are cached by self.player, so will be the same instance for the copy. + new.objectives = self.objectives + # Copy the facade functions from new.helpers into new.__dict__. + new.createFacadeFunctions() + # Copying the existing 'knows' functions from `self` to `new` is faster than re-creating all the lambdas with + # `new.createKnowsFunctions(player)`. + for key in Knows.__dict__.keys(): + if isKnows(key): + attribute_name = "knows"+key + knows_func = getattr(self, attribute_name) + setattr(new, attribute_name, knows_func) + # There is no need to call `new.resetItems()` because `_items` and `_counts` have been copied over. + # new.resetItems() + # itemsPositions is a `dict[str, tuple[int, int]]`, so the dict can be copied because the keys and values are + # immutable. + new.itemsPositions = self.itemsPositions.copy() + + return new def computeItemsPositions(self): # compute index in cache key for each items @@ -245,6 +286,9 @@ class SMBoolManagerPlando(SMBoolManager): def __init__(self): super(SMBoolManagerPlando, self).__init__() + def __deepcopy__(self, memodict): + return super().__deepcopy__(memodict) + def addItem(self, item): # a new item is available already = self.haveItem(item) From 6125e59ce3360d7c4a9aa97157489c180e12c16a Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Tue, 29 Jul 2025 22:33:33 -0400 Subject: [PATCH 08/29] Docs: Don't Suggest exclude in create_items (#5256) --- docs/world api.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index 17cf81fe92..e8932cfd83 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -612,17 +612,10 @@ def create_items(self) -> None: # If there are two of the same item, the item has to be twice in the pool. # Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt. # Having an item in the start inventory won't remove it from the pool. - # If an item can't have duplicates it has to be excluded manually. - - # List of items to exclude, as a copy since it will be destroyed below - exclude = [item for item in self.multiworld.precollected_items[self.player]] + # If you want to do that, use start_inventory_from_pool for item in map(self.create_item, mygame_items): - if item in exclude: - exclude.remove(item) # this is destructive. create unique list above - self.multiworld.itempool.append(self.create_item("nothing")) - else: - self.multiworld.itempool.append(item) + self.multiworld.itempool.append(item) # itempool and number of locations should match up. # If this is not the case we want to fill the itempool with junk. From 743501addc8e7c4f5b93b46ec9555cc787de5487 Mon Sep 17 00:00:00 2001 From: qwint Date: Tue, 29 Jul 2025 21:42:55 -0500 Subject: [PATCH 09/29] Docs: Remove Settings API Back Compat Section (#5255) --- docs/settings api.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/settings api.md b/docs/settings api.md index ef1f20d098..d701c01758 100644 --- a/docs/settings api.md +++ b/docs/settings api.md @@ -181,10 +181,3 @@ circular / partial imports. Instead, the code should fetch from settings on dema "Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary, "global" settings could be used in global scope of worlds. - - -### APWorld Backwards Compatibility - -APWorlds that want to be compatible with both stable and dev versions, have two options: -1. use the old Utils.get_options() API until Archipelago 0.4.2 is out -2. add some sort of compatibility code to your world that mimics the new API From 8a552e36392166980f949e9a0c37e74ead2b6453 Mon Sep 17 00:00:00 2001 From: Solidus Snake <63137482+TheRealSolidusSnake@users.noreply.github.com> Date: Wed, 30 Jul 2025 07:40:01 -0400 Subject: [PATCH 10/29] SMZ3: Fix Junk Item Overflow (#5162) Removed `self.junkItemsNames = [item.Type.name for item in junkItems]` from `create_items` as that was pulling massive amounts of HeartPieces (because they're in junkItems in upstream) to be added if the start_inventory_from_pool was extensive. Getting more than 20 Heart Containers can lead to OHKO situations. ETank was also removed as a junk item that can be used as filler in the earlier defined list of junk items that AP allows since you should only have 14 in the pool. It's not a problem to have more per se, but you really shouldn't have 27 of them in the pool, either. Ammo and such is much less of a problem to have crazy amounts of. --- worlds/smz3/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index a98ae11df3..4d0b63f33c 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -97,7 +97,6 @@ class SMZ3World(World): ItemType.TwentyRupees, ItemType.FiftyRupees, ItemType.ThreeHundredRupees, - ItemType.ETank, ItemType.Missile, ItemType.Super, ItemType.PowerBomb @@ -231,7 +230,6 @@ class SMZ3World(World): niceItems = TotalSMZ3Item.Item.CreateNicePool(self.smz3World) junkItems = TotalSMZ3Item.Item.CreateJunkPool(self.smz3World) - self.junkItemsNames = [item.Type.name for item in junkItems] if (self.smz3World.Config.Keysanity): progressionItems = self.progression + self.dungeon + self.keyCardsItems + self.SmMapsItems From 7abe7fe304d6895d92c50436dcf7e2c99f18b6fd Mon Sep 17 00:00:00 2001 From: josephwhite <22449090+josephwhite@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:09:00 -0400 Subject: [PATCH 11/29] ALTTP/SNIC/BHC: Stop using Utils.get_settings() (#5239) * LTTP/SNIC/BHC: Stop using Utils.get_settings() * SNIClient: use Settings.sni_options --- SNIClient.py | 6 +++--- worlds/_bizhawk/context.py | 5 +++-- worlds/alttp/Rom.py | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/SNIClient.py b/SNIClient.py index 1156bf6040..d8bc05841f 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -18,6 +18,7 @@ from json import loads, dumps from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser import Utils +from settings import Settings from Utils import async_start from MultiServer import mark_raw if typing.TYPE_CHECKING: @@ -285,7 +286,7 @@ class SNESState(enum.IntEnum): def launch_sni() -> None: - sni_path = Utils.get_settings()["sni_options"]["sni_path"] + sni_path = Settings.sni_options.sni_path if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) @@ -668,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None: async def run_game(romfile: str) -> None: - auto_start = typing.cast(typing.Union[bool, str], - Utils.get_settings()["sni_options"].get("snes_rom_start", True)) + auto_start = Settings.sni_options.snes_rom_start if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index 250e4a8826..142c296400 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -9,6 +9,7 @@ import enum import subprocess from typing import Any +import settings from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled import Patch import Utils @@ -304,10 +305,10 @@ async def _game_watcher(ctx: BizHawkClientContext): async def _run_game(rom: str): import os - auto_start = Utils.get_settings().bizhawkclient_options.rom_start + auto_start = settings.get_settings().bizhawkclient_options.rom_start if auto_start is True: - emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path + emuhawk_path = settings.get_settings().bizhawkclient_options.emuhawk_path subprocess.Popen( [ emuhawk_path, diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index 99cc78e2d9..399d64d433 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -1,6 +1,7 @@ from __future__ import annotations import Utils +import settings import worlds.Files LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173" @@ -3023,7 +3024,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_settings() + options = settings.get_settings() if not file_name: file_name = options["lttp_options"]["rom_file"] if not os.path.exists(file_name): From 754e0a0de47d564840c9949fb5e1b6214afcff05 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Thu, 31 Jul 2025 14:42:42 -0500 Subject: [PATCH 12/29] Core: hard deprecate per_slot_randoms (#3382) Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- BaseClasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index 10d0540633..4b2c66434f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -183,7 +183,7 @@ class MultiWorld(): set_player_attr('completion_condition', lambda state: True) self.worlds = {} self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " - "world's random object instead (usually self.random)") + "world's random object instead (usually self.random)", True) self.plando_options = PlandoOptions.none def get_all_ids(self) -> Tuple[int, ...]: From b1f729a9704c8de297cf253bb50930bb99b54c1f Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Thu, 31 Jul 2025 14:33:56 -0600 Subject: [PATCH 13/29] Core: Remove Checks for Unsupported Versions (#5067) * Remove redundant version checks/compatibility * Change windows7 check * Edit comments Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --------- Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- Options.py | 2 +- Utils.py | 3 +-- setup.py | 10 +++------- test/benchmark/locations.py | 11 +++-------- worlds/__init__.py | 4 +--- 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/Options.py b/Options.py index 3e67d68569..47d6c2d387 100644 --- a/Options.py +++ b/Options.py @@ -1118,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple): entrance: str exit: str - direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped + direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped percentage: int = 100 diff --git a/Utils.py b/Utils.py index abf359f43e..b7616b57b1 100644 --- a/Utils.py +++ b/Utils.py @@ -953,8 +953,7 @@ def _extend_freeze_support() -> None: # Handle the first process that MP will create if ( len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( - 'from multiprocessing.semaphore_tracker import main', # Py<3.8 - 'from multiprocessing.resource_tracker import main', # Py>=3.8 + 'from multiprocessing.resource_tracker import main', 'from multiprocessing.forkserver import main' )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) ): diff --git a/setup.py b/setup.py index 704325d70c..c24a443526 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,6 @@ from Utils import version_tuple, is_windows, is_linux from Cython.Build import cythonize -# On Python < 3.10 LogicMixin is not currently supported. non_apworlds: set[str] = { "A Link to the Past", "Adventure", @@ -78,9 +77,6 @@ non_apworlds: set[str] = { "Wargroove", } -# LogicMixin is broken before 3.10 import revamp -if sys.version_info < (3,10): - non_apworlds.add("Hollow Knight") def download_SNI() -> None: print("Updating SNI") @@ -108,8 +104,8 @@ def download_SNI() -> None: # prefer "many" builds if "many" in download_url: break - # prefer the correct windows or windows7 build - if platform_name == "windows" and ("windows7" in download_url) == (sys.version_info < (3, 9)): + # prefer non-windows7 builds to get up-to-date dependencies + if platform_name == "windows" and "windows7" not in download_url: break if source_url and source_url.endswith(".zip"): @@ -418,7 +414,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe): if is_windows: # Inno setup stuff with open("setup.ini", "w") as f: - min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000" + min_supported_windows = "6.2.9200" f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n") with open("installdelete.iss", "w") as f: f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n" diff --git a/test/benchmark/locations.py b/test/benchmark/locations.py index 16667a17b9..0e496cd3ee 100644 --- a/test/benchmark/locations.py +++ b/test/benchmark/locations.py @@ -29,14 +29,9 @@ def run_locations_benchmark(): rule_iterations: int = 100_000 - if sys.version_info >= (3, 9): - @staticmethod - def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: - return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) - else: - @staticmethod - def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str: - return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) + @staticmethod + def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str: + return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: with TimeIt(f"{test_location.game} {self.rule_iterations} " diff --git a/worlds/__init__.py b/worlds/__init__.py index 80240275b0..89f7bcd063 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -63,9 +63,7 @@ class WorldSource: sys.modules[mod.__name__] = mod with warnings.catch_warnings(): warnings.filterwarnings("ignore", message="__package__ != __spec__.parent") - # Found no equivalent for < 3.10 - if hasattr(importer, "exec_module"): - importer.exec_module(mod) + importer.exec_module(mod) else: importlib.import_module(f".{self.path}", "worlds") self.time_taken = time.perf_counter()-start From 2fe51d087f97ff718ad2421a630335fff6ca4a56 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:43:34 +0200 Subject: [PATCH 14/29] CI: also use new appimage tool in release action --- .github/workflows/release.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a500f9a23b..1462560052 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,12 @@ on: env: ENEMIZER_VERSION: 7.1 - APPIMAGETOOL_VERSION: 13 + # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, + # we check the sha256 and require manual intervention if it was updated. + APPIMAGETOOL_VERSION: continuous + APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' + APPIMAGE_RUNTIME_VERSION: continuous + APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e' permissions: # permissions required for attestation id-token: 'write' @@ -122,10 +127,13 @@ jobs: - name: Install build-time dependencies run: | echo "PYTHON=python3.12" >> $GITHUB_ENV - wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage + wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage + echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c + wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64 + echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract - echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool + echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool chmod a+rx appimagetool - name: Download run-time dependencies run: | From 8c07a2c930538dc45f5be2db0b883673c85320e3 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 1 Aug 2025 00:43:08 +0200 Subject: [PATCH 15/29] WebHost: turn module discovery dynamic (#5218) --- WebHostLib/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index e928b8f3b1..74086cb884 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -87,12 +87,17 @@ app.jinja_env.filters["title_sorted"] = title_sorted def register(): """Import submodules, triggering their registering on flask routing. Note: initializes worlds subsystem.""" + import importlib + + from werkzeug.utils import find_modules # has automatic patch integration import worlds.Files app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container from WebHostLib.customserver import run_server_process - # to trigger app routing picking up on it - from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session + for module in find_modules("WebHostLib", include_packages=True): + importlib.import_module(module) + + from . import api app.register_blueprint(api.api_endpoints) From e7131eddc286dd252c91c391b8ecb796431a7e6d Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 1 Aug 2025 00:43:43 +0200 Subject: [PATCH 16/29] Setup: update cert signing process (#5161) --- setup.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index c24a443526..593a45f830 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,7 @@ import subprocess import sys import sysconfig import threading +import urllib.error import urllib.request import warnings import zipfile @@ -144,15 +145,16 @@ def download_SNI() -> None: print(f"No SNI found for system spec {platform_name} {machine_name}") -signtool: str | None -if os.path.exists("X:/pw.txt"): - print("Using signtool") - with open("X:/pw.txt", encoding="utf-8-sig") as f: - pw = f.read() - signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \ - r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ ' -else: - signtool = None +signtool: str | None = None +try: + with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response: + html = response.read() + if b"status=OK\n" in html: + signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 ' + r'/tr http://timestamp.digicert.com/ ') + print("Using signtool") +except (ConnectionError, TimeoutError, urllib.error.URLError) as e: + pass build_platform = sysconfig.get_platform() From 332f955159bb6817be427be4b2b6d930ba0aba6d Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Fri, 1 Aug 2025 01:16:54 +0200 Subject: [PATCH 17/29] =?UTF-8?q?The=20Witness:=20Comply=20with=20new=20te?= =?UTF-8?q?st=20base=20structure=C2=A0#5265?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/witness/test/__init__.py | 196 ------------------ worlds/witness/test/bases.py | 196 ++++++++++++++++++ worlds/witness/test/test_auto_elevators.py | 2 +- .../test/test_disable_non_randomized.py | 2 +- worlds/witness/test/test_door_shuffle.py | 2 +- .../witness/test/test_easter_egg_shuffle.py | 2 +- worlds/witness/test/test_ep_shuffle.py | 2 +- worlds/witness/test/test_lasers.py | 2 +- worlds/witness/test/test_panel_hunt.py | 2 +- .../witness/test/test_roll_other_options.py | 2 +- worlds/witness/test/test_symbol_shuffle.py | 2 +- worlds/witness/test/test_weird_traversals.py | 2 +- 12 files changed, 206 insertions(+), 206 deletions(-) create mode 100644 worlds/witness/test/bases.py diff --git a/worlds/witness/test/__init__.py b/worlds/witness/test/__init__.py index c3b427851a..e69de29bb2 100644 --- a/worlds/witness/test/__init__.py +++ b/worlds/witness/test/__init__.py @@ -1,196 +0,0 @@ -from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union - -from BaseClasses import CollectionState, Entrance, Item, Location, Region - -from test.bases import WorldTestBase -from test.general import gen_steps, setup_multiworld -from test.multiworld.test_multiworlds import MultiworldTestBase - -from .. import WitnessWorld -from ..data.utils import cast_not_none - - -class WitnessTestBase(WorldTestBase): - game = "The Witness" - player: ClassVar[int] = 1 - - world: WitnessWorld - - def can_beat_game_with_items(self, items: Iterable[Item]) -> bool: - """ - Check that the items listed are enough to beat the game. - """ - - state = CollectionState(self.multiworld) - for item in items: - state.collect(item) - return state.multiworld.can_beat_game(state) - - def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None: - """ - WorldTestBase.assertAccessDependency, but modified & simplified to work with event items - """ - event_items = [item for item in self.multiworld.get_items() if item.name == item_name] - self.assertTrue(event_items, f"Event item {item_name} does not exist.") - - event_locations = [cast_not_none(event_item.location) for event_item in event_items] - - # Checking for an access dependency on an event item requires a bit of extra work, - # as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it. - # So, we temporarily set the access rules of the event locations to be impossible. - original_rules = {event_location.name: event_location.access_rule for event_location in event_locations} - for event_location in event_locations: - event_location.access_rule = lambda _: False - - # We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30) - test_state = self.multiworld.get_all_state(False) - - self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}") - - test_state.collect(event_items[0]) - - self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}") - - # Restore original access rules. - for event_location in event_locations: - event_location.access_rule = original_rules[event_location.name] - - def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, also make sure that this (non-event) location COULD exist. - """ - - if strict_check: - self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") - - try: - self.world.get_location(location_name) - except KeyError: - self.fail(f"Location {location_name} does not exist.") - - def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, be explicit about whether the location could exist in the first place. - """ - - if strict_check: - self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") - - self.assertRaises( - KeyError, - lambda _: self.world.get_location(location_name), - f"Location {location_name} exists, but is not supposed to.", - ) - - def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None: - """ - Assert that the specified mapping of items is enough to beat the game, - and that having one less of any item would result in the game being unbeatable. - """ - # Find the actual items - found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts] - actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts} - for item in found_items: - if len(actual_items[item.name]) < required_item_counts[item.name]: - actual_items[item.name].append(item) - - # Assert that enough items exist in the item pool to satisfy the specified required counts - for item_name, item_objects in actual_items.items(): - self.assertEqual( - len(item_objects), - required_item_counts[item_name], - f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, " - f"only found {len(item_objects)}", - ) - - # assert that multiworld is beatable with the items specified - self.assertTrue( - self.can_beat_game_with_items(item for items in actual_items.values() for item in items), - f"Could not beat game with items: {required_item_counts}", - ) - - # assert that one less copy of any item would result in the multiworld being unbeatable - for item_name, item_objects in actual_items.items(): - with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"): - removed_item = item_objects.pop() - self.assertFalse( - self.can_beat_game_with_items(item for items in actual_items.values() for item in items), - f"Game was beatable despite having {len(item_objects)} copies of {item_name} " - f"instead of the specified {required_item_counts[item_name]}", - ) - item_objects.append(removed_item) - - -class WitnessMultiworldTestBase(MultiworldTestBase): - options_per_world: List[Dict[str, Any]] - common_options: Dict[str, Any] = {} - - def setUp(self) -> None: - """ - Set up a multiworld with multiple players, each using different options. - """ - - self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ()) - - for world, options in zip(self.multiworld.worlds.values(), self.options_per_world): - for option_name, option_value in {**self.common_options, **options}.items(): - option = getattr(world.options, option_name) - self.assertIsNotNone(option) - - option.value = option.from_any(option_value).value - - self.assertSteps(gen_steps) - - def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: - """ - Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool. - """ - - items = self.get_items_by_name(item_names, player) - for item in items: - self.multiworld.state.collect(item) - return items - - def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: - """ - Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool. - """ - - if isinstance(item_names, str): - item_names = (item_names,) - return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player] - - def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, also make sure that this (non-event) location COULD exist. - """ - - world = self.multiworld.worlds[player] - - if strict_check: - self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") - - try: - world.get_location(location_name) - except KeyError: - self.fail(f"Location {location_name} does not exist.") - - def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None: - """ - Assert that a location exists in this world. - If strict_check, be explicit about whether the location could exist in the first place. - """ - - world = self.multiworld.worlds[player] - - if strict_check: - self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") - - self.assertRaises( - KeyError, - lambda _: world.get_location(location_name), - f"Location {location_name} exists, but is not supposed to.", - ) diff --git a/worlds/witness/test/bases.py b/worlds/witness/test/bases.py new file mode 100644 index 0000000000..c3b427851a --- /dev/null +++ b/worlds/witness/test/bases.py @@ -0,0 +1,196 @@ +from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union + +from BaseClasses import CollectionState, Entrance, Item, Location, Region + +from test.bases import WorldTestBase +from test.general import gen_steps, setup_multiworld +from test.multiworld.test_multiworlds import MultiworldTestBase + +from .. import WitnessWorld +from ..data.utils import cast_not_none + + +class WitnessTestBase(WorldTestBase): + game = "The Witness" + player: ClassVar[int] = 1 + + world: WitnessWorld + + def can_beat_game_with_items(self, items: Iterable[Item]) -> bool: + """ + Check that the items listed are enough to beat the game. + """ + + state = CollectionState(self.multiworld) + for item in items: + state.collect(item) + return state.multiworld.can_beat_game(state) + + def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None: + """ + WorldTestBase.assertAccessDependency, but modified & simplified to work with event items + """ + event_items = [item for item in self.multiworld.get_items() if item.name == item_name] + self.assertTrue(event_items, f"Event item {item_name} does not exist.") + + event_locations = [cast_not_none(event_item.location) for event_item in event_items] + + # Checking for an access dependency on an event item requires a bit of extra work, + # as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it. + # So, we temporarily set the access rules of the event locations to be impossible. + original_rules = {event_location.name: event_location.access_rule for event_location in event_locations} + for event_location in event_locations: + event_location.access_rule = lambda _: False + + # We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30) + test_state = self.multiworld.get_all_state(False) + + self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}") + + test_state.collect(event_items[0]) + + self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}") + + # Restore original access rules. + for event_location in event_locations: + event_location.access_rule = original_rules[event_location.name] + + def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, also make sure that this (non-event) location COULD exist. + """ + + if strict_check: + self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") + + try: + self.world.get_location(location_name) + except KeyError: + self.fail(f"Location {location_name} does not exist.") + + def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, be explicit about whether the location could exist in the first place. + """ + + if strict_check: + self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist") + + self.assertRaises( + KeyError, + lambda _: self.world.get_location(location_name), + f"Location {location_name} exists, but is not supposed to.", + ) + + def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None: + """ + Assert that the specified mapping of items is enough to beat the game, + and that having one less of any item would result in the game being unbeatable. + """ + # Find the actual items + found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts] + actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts} + for item in found_items: + if len(actual_items[item.name]) < required_item_counts[item.name]: + actual_items[item.name].append(item) + + # Assert that enough items exist in the item pool to satisfy the specified required counts + for item_name, item_objects in actual_items.items(): + self.assertEqual( + len(item_objects), + required_item_counts[item_name], + f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, " + f"only found {len(item_objects)}", + ) + + # assert that multiworld is beatable with the items specified + self.assertTrue( + self.can_beat_game_with_items(item for items in actual_items.values() for item in items), + f"Could not beat game with items: {required_item_counts}", + ) + + # assert that one less copy of any item would result in the multiworld being unbeatable + for item_name, item_objects in actual_items.items(): + with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"): + removed_item = item_objects.pop() + self.assertFalse( + self.can_beat_game_with_items(item for items in actual_items.values() for item in items), + f"Game was beatable despite having {len(item_objects)} copies of {item_name} " + f"instead of the specified {required_item_counts[item_name]}", + ) + item_objects.append(removed_item) + + +class WitnessMultiworldTestBase(MultiworldTestBase): + options_per_world: List[Dict[str, Any]] + common_options: Dict[str, Any] = {} + + def setUp(self) -> None: + """ + Set up a multiworld with multiple players, each using different options. + """ + + self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ()) + + for world, options in zip(self.multiworld.worlds.values(), self.options_per_world): + for option_name, option_value in {**self.common_options, **options}.items(): + option = getattr(world.options, option_name) + self.assertIsNotNone(option) + + option.value = option.from_any(option_value).value + + self.assertSteps(gen_steps) + + def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: + """ + Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool. + """ + + items = self.get_items_by_name(item_names, player) + for item in items: + self.multiworld.state.collect(item) + return items + + def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]: + """ + Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool. + """ + + if isinstance(item_names, str): + item_names = (item_names,) + return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player] + + def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, also make sure that this (non-event) location COULD exist. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + try: + world.get_location(location_name) + except KeyError: + self.fail(f"Location {location_name} does not exist.") + + def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, be explicit about whether the location could exist in the first place. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + self.assertRaises( + KeyError, + lambda _: world.get_location(location_name), + f"Location {location_name} exists, but is not supposed to.", + ) diff --git a/worlds/witness/test/test_auto_elevators.py b/worlds/witness/test/test_auto_elevators.py index f91943e855..6762657b8e 100644 --- a/worlds/witness/test/test_auto_elevators.py +++ b/worlds/witness/test/test_auto_elevators.py @@ -1,4 +1,4 @@ -from ..test import WitnessMultiworldTestBase +from ..test.bases import WitnessMultiworldTestBase class TestElevatorsComeToYouBleed(WitnessMultiworldTestBase): diff --git a/worlds/witness/test/test_disable_non_randomized.py b/worlds/witness/test/test_disable_non_randomized.py index bf285f035d..00071ec5f6 100644 --- a/worlds/witness/test/test_disable_non_randomized.py +++ b/worlds/witness/test/test_disable_non_randomized.py @@ -1,5 +1,5 @@ from ..rules import _has_lasers -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestDisableNonRandomized(WitnessTestBase): diff --git a/worlds/witness/test/test_door_shuffle.py b/worlds/witness/test/test_door_shuffle.py index ca4d6e0aa8..be0a3332f3 100644 --- a/worlds/witness/test/test_door_shuffle.py +++ b/worlds/witness/test/test_door_shuffle.py @@ -1,7 +1,7 @@ from typing import cast from .. import WitnessWorld -from ..test import WitnessMultiworldTestBase, WitnessTestBase +from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestIndividualDoors(WitnessTestBase): diff --git a/worlds/witness/test/test_easter_egg_shuffle.py b/worlds/witness/test/test_easter_egg_shuffle.py index 300d32f97f..a95357c6e1 100644 --- a/worlds/witness/test/test_easter_egg_shuffle.py +++ b/worlds/witness/test/test_easter_egg_shuffle.py @@ -3,7 +3,7 @@ from typing import cast from BaseClasses import LocationProgressType from .. import WitnessWorld -from ..test import WitnessMultiworldTestBase +from ..test.bases import WitnessMultiworldTestBase class TestEasterEggShuffle(WitnessMultiworldTestBase): diff --git a/worlds/witness/test/test_ep_shuffle.py b/worlds/witness/test/test_ep_shuffle.py index 3423909166..17297fbcf3 100644 --- a/worlds/witness/test/test_ep_shuffle.py +++ b/worlds/witness/test/test_ep_shuffle.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestIndividualEPs(WitnessTestBase): diff --git a/worlds/witness/test/test_lasers.py b/worlds/witness/test/test_lasers.py index 5e60dfc521..5681757161 100644 --- a/worlds/witness/test/test_lasers.py +++ b/worlds/witness/test/test_lasers.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestSymbolsRequiredToWinElevatorNormal(WitnessTestBase): diff --git a/worlds/witness/test/test_panel_hunt.py b/worlds/witness/test/test_panel_hunt.py index 2f8434802b..6dea655070 100644 --- a/worlds/witness/test/test_panel_hunt.py +++ b/worlds/witness/test/test_panel_hunt.py @@ -1,6 +1,6 @@ from BaseClasses import CollectionState -from worlds.witness.test import WitnessMultiworldTestBase, WitnessTestBase +from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestMaxPanelHuntMinChecks(WitnessTestBase): diff --git a/worlds/witness/test/test_roll_other_options.py b/worlds/witness/test/test_roll_other_options.py index 05f3235a1f..72313034e4 100644 --- a/worlds/witness/test/test_roll_other_options.py +++ b/worlds/witness/test/test_roll_other_options.py @@ -1,5 +1,5 @@ from ..options import ElevatorsComeToYou -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase # These are just some random options combinations, just to catch whether I broke anything obvious diff --git a/worlds/witness/test/test_symbol_shuffle.py b/worlds/witness/test/test_symbol_shuffle.py index 3be874f3c0..fb1d820815 100644 --- a/worlds/witness/test/test_symbol_shuffle.py +++ b/worlds/witness/test/test_symbol_shuffle.py @@ -1,4 +1,4 @@ -from ..test import WitnessMultiworldTestBase, WitnessTestBase +from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestSymbols(WitnessTestBase): diff --git a/worlds/witness/test/test_weird_traversals.py b/worlds/witness/test/test_weird_traversals.py index 47b69b01fb..9447a13922 100644 --- a/worlds/witness/test/test_weird_traversals.py +++ b/worlds/witness/test/test_weird_traversals.py @@ -1,4 +1,4 @@ -from ..test import WitnessTestBase +from ..test.bases import WitnessTestBase class TestWeirdTraversalRequirements(WitnessTestBase): From 8bb236411d09604b5fe50f689ba0815587bed1fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wilhelm=20Sch=C3=BCrmann?= Date: Fri, 1 Aug 2025 20:01:18 +0200 Subject: [PATCH 18/29] Various: Make clients wait a second between connects (#5061) --- AdventureClient.py | 1 + MMBN3Client.py | 1 + OoTClient.py | 1 + Zelda1Client.py | 1 + 4 files changed, 4 insertions(+) diff --git a/AdventureClient.py b/AdventureClient.py index a4839c902d..b89b8f0600 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -407,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.atari_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue except CancelledError: pass diff --git a/MMBN3Client.py b/MMBN3Client.py index bdf1427475..31c6b309b8 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -286,6 +286,7 @@ async def gba_sync_task(ctx: MMBN3Context): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.gba_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue diff --git a/OoTClient.py b/OoTClient.py index 571300ed36..2b0c7e4966 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -277,6 +277,7 @@ async def n64_sync_task(ctx: OoTContext): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.n64_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue diff --git a/Zelda1Client.py b/Zelda1Client.py index 4473b3f3c7..9753621ef0 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -333,6 +333,7 @@ async def nes_sync_task(ctx: ZeldaContext): except ConnectionRefusedError: logger.debug("Connection Refused, Trying Again") ctx.nes_status = CONNECTION_REFUSED_STATUS + await asyncio.sleep(1) continue From e8f5bc1c9677d4146c8c9dcb2f3321916a6992f6 Mon Sep 17 00:00:00 2001 From: Jonathan Tan Date: Fri, 1 Aug 2025 14:39:57 -0400 Subject: [PATCH 19/29] TWW: Fix Death Link (#5270) --- worlds/tww/Options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/tww/Options.py b/worlds/tww/Options.py index d02c606f9f..b6f2c1511b 100644 --- a/worlds/tww/Options.py +++ b/worlds/tww/Options.py @@ -800,6 +800,7 @@ class TWWOptions(PerGameCommonOptions): "swift_sail", "skip_rematch_bosses", "remove_music", + "death_link", ) def get_output_dict(self) -> dict[str, Any]: From 37a9d9486544873783a75db3db493dabb999502c Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 1 Aug 2025 15:06:35 -0500 Subject: [PATCH 20/29] Core: Purge Multiworld.option_name (#5050) --- BaseClasses.py | 9 --------- Main.py | 2 +- worlds/AutoWorld.py | 12 ------------ worlds/alttp/Dungeons.py | 9 +++++---- 4 files changed, 6 insertions(+), 26 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 4b2c66434f..77cad22deb 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -228,17 +228,8 @@ class MultiWorld(): self.seed_name = name if name else str(self.seed) def set_options(self, args: Namespace) -> None: - # TODO - remove this section once all worlds use options dataclasses from worlds import AutoWorld - all_keys: Set[str] = {key for player in self.player_ids for key in - AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints} - for option_key in all_keys: - option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. " - f"Please use `self.options.{option_key}` instead.", True) - option.update(getattr(args, option_key, {})) - setattr(self, option_key, option) - for player in self.player_ids: world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) diff --git a/Main.py b/Main.py index 67c861c0f4..bc2787579f 100644 --- a/Main.py +++ b/Main.py @@ -176,7 +176,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) multiworld.link_items() - if any(multiworld.item_links.values()): + if any(world.options.item_links for world in multiworld.worlds.values()): multiworld._all_state = None logger.info("Running Item Plando.") diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 568bdcf9a4..9233f3d217 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -72,15 +72,6 @@ class AutoWorldRegister(type): dct["required_client_version"] = max(dct["required_client_version"], base.__dict__["required_client_version"]) - # create missing options_dataclass from legacy option_definitions - # TODO - remove this once all worlds use options dataclasses - if "options_dataclass" not in dct and "option_definitions" in dct: - # TODO - switch to deprecate after a version - deprecate(f"{name} Assigned options through option_definitions which is now deprecated. " - "Please use options_dataclass instead.") - dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(), - bases=(PerGameCommonOptions,)) - # construct class new_class = super().__new__(mcs, name, bases, dct) new_class.__file__ = sys.modules[new_class.__module__].__file__ @@ -493,9 +484,6 @@ class World(metaclass=AutoWorldRegister): Creates a group, which is an instance of World that is responsible for multiple others. An example case is ItemLinks creating these. """ - # TODO remove loop when worlds use options dataclass - for option_key, option in cls.options_dataclass.type_hints.items(): - getattr(multiworld, option_key)[new_player_id] = option.from_any(option.default) group = cls(multiworld, new_player_id) group.options = cls.options_dataclass(**{option_key: option.from_any(option.default) for option_key, option in cls.options_dataclass.type_hints.items()}) diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index 39e8d7072b..6b7da69593 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -209,8 +209,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld): if localized: in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized] if in_dungeon_items: - restricted_players = {player for player, restricted in multiworld.restrict_dungeon_item_on_boss.items() if - restricted} + restricted_players = {world.player for world in multiworld.get_game_worlds("A Link to the Past") if + world.options.restrict_dungeon_item_on_boss} locations: typing.List["ALttPLocation"] = [ location for location in get_unfilled_dungeon_locations(multiworld) # filter boss @@ -255,8 +255,9 @@ def fill_dungeons_restrictive(multiworld: MultiWorld): if all_state_base.has("Triforce", player): all_state_base.remove(multiworld.worlds[player].create_item("Triforce")) - for (player, key_drop_shuffle) in multiworld.key_drop_shuffle.items(): - if not key_drop_shuffle and player not in multiworld.groups: + for lttp_world in multiworld.get_game_worlds("A Link to the Past"): + if not lttp_world.options.key_drop_shuffle and lttp_world.player not in multiworld.groups: + player = lttp_world.player for key_loc in key_drop_data: key_data = key_drop_data[key_loc] all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player])) From 9ad6959559f8926e15b2a5c7e869125c76878159 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 1 Aug 2025 22:30:30 +0200 Subject: [PATCH 21/29] LttP: move more stuff out of core (#5049) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- BaseClasses.py | 6 ---- worlds/alttp/ItemPool.py | 11 +++---- worlds/alttp/Rom.py | 22 ++++++------- worlds/alttp/Rules.py | 24 +++++++------- worlds/alttp/Shops.py | 61 ++++++++++++++++++------------------ worlds/alttp/StateHelpers.py | 8 ++--- worlds/alttp/__init__.py | 9 +++++- 7 files changed, 70 insertions(+), 71 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 77cad22deb..a9477a0312 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -154,17 +154,11 @@ class MultiWorld(): self.algorithm = 'balanced' self.groups = {} self.regions = self.RegionManager(players) - self.shops = [] self.itempool = [] self.seed = None self.seed_name: str = "Unavailable" self.precollected_items = {player: [] for player in self.player_ids} self.required_locations = [] - self.light_world_light_cone = False - self.dark_world_light_cone = False - self.rupoor_cost = 10 - self.aga_randomness = True - self.save_and_quit_from_boss = True self.custom = False self.customitemarray = [] self.shuffle_ganon = True diff --git a/worlds/alttp/ItemPool.py b/worlds/alttp/ItemPool.py index 9f1a58e546..53059c64bc 100644 --- a/worlds/alttp/ItemPool.py +++ b/worlds/alttp/ItemPool.py @@ -223,7 +223,7 @@ items_reduction_table = ( def generate_itempool(world): - player = world.player + player: int = world.player multiworld = world.multiworld if world.options.item_pool.current_key not in difficulties: @@ -280,7 +280,6 @@ def generate_itempool(world): if multiworld.custom: pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = ( make_custom_item_pool(multiworld, player)) - multiworld.rupoor_cost = min(multiworld.customitemarray[67], 9999) else: (pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total, additional_triforce_pieces) = get_pool_core(multiworld, player) @@ -386,8 +385,8 @@ def generate_itempool(world): if world.options.retro_bow: shop_items = 0 - shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if - shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if + shop_locations = [location for shop_locations in (shop.region.locations for shop in world.shops if + shop.type == ShopType.Shop) for location in shop_locations if location.shop_slot is not None] for location in shop_locations: if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow": @@ -546,7 +545,7 @@ def set_up_take_anys(multiworld, world, player): connect_entrance(multiworld, entrance.name, old_man_take_any.name, player) entrance.target = 0x58 old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots) - multiworld.shops.append(old_man_take_any.shop) + world.shops.append(old_man_take_any.shop) sword_indices = [ index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword' @@ -574,7 +573,7 @@ def set_up_take_anys(multiworld, world, player): connect_entrance(multiworld, entrance.name, take_any.name, player) entrance.target = target take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1) - multiworld.shops.append(take_any.shop) + world.shops.append(take_any.shop) take_any.shop.add_inventory(0, 'Blue Potion', 0, 0) take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0) location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any) diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index 399d64d433..88b9485aaf 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -1002,14 +1002,19 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): # set light cones rom.write_byte(0x180038, 0x01 if local_world.options.mode == "standard" else 0x00) - rom.write_byte(0x180039, 0x01 if world.light_world_light_cone else 0x00) - rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00) + # light world light cone + rom.write_byte(0x180039, local_world.light_world_light_cone) + # dark world light cone + rom.write_byte(0x18003A, local_world.dark_world_light_cone) GREEN_TWENTY_RUPEES = 0x47 GREEN_CLOCK = item_table["Green Clock"].item_code rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on + # Rupoor negative value + rom.write_int16(0x180036, local_world.rupoor_cost) + # handle item_functionality if local_world.options.item_functionality == 'hard': rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon @@ -1027,8 +1032,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): # Disable catching fairies rom.write_byte(0x34FD6, 0x80) overflow_replacement = GREEN_TWENTY_RUPEES - # Rupoor negative value - rom.write_int16(0x180036, world.rupoor_cost) # Set stun items rom.write_byte(0x180180, 0x02) # Hookshot only elif local_world.options.item_functionality == 'expert': @@ -1047,8 +1050,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): # Disable catching fairies rom.write_byte(0x34FD6, 0x80) overflow_replacement = GREEN_TWENTY_RUPEES - # Rupoor negative value - rom.write_int16(0x180036, world.rupoor_cost) # Set stun items rom.write_byte(0x180180, 0x00) # Nothing else: @@ -1066,8 +1067,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): rom.write_byte(0x18004F, 0x01) # Enable catching fairies rom.write_byte(0x34FD6, 0xF0) - # Rupoor negative value - rom.write_int16(0x180036, world.rupoor_cost) # Set stun items rom.write_byte(0x180180, 0x03) # All standard items # Set overflow items for progressive equipment @@ -1313,7 +1312,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): rom.write_byte(0x18008C, 0x01 if local_world.options.crystals_needed_for_gt == 0 else 0x00) # GT pre-opened if crystal requirement is 0 rom.write_byte(0xF5D73, 0xF0) # bees are catchable rom.write_byte(0xF5F10, 0xF0) # bees are catchable - rom.write_byte(0x180086, 0x00 if world.aga_randomness else 0x01) # set blue ball and ganon warp randomness + rom.write_byte(0x180086, 0x00) # set blue ball and ganon warp randomness rom.write_byte(0x1800A0, 0x01) # return to light world on s+q without mirror rom.write_byte(0x1800A1, 0x01) # enable overworld screen transition draining for water level inside swamp rom.write_byte(0x180174, 0x01 if local_world.fix_fake_world else 0x00) @@ -1618,7 +1617,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool): rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills rom.write_byte(0x1800A4, 0x01 if local_world.options.glitches_required != 'no_logic' else 0x00) # enable POD EG fix rom.write_byte(0x186383, 0x01 if local_world.options.glitches_required == 'no_logic' else 0x00) # disable glitching to Triforce from Ganons Room - rom.write_byte(0x180042, 0x01 if world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill + rom.write_byte(0x180042, 0x01 if local_world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill # remove shield from uncle rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E]) @@ -1739,8 +1738,7 @@ def get_price_data(price: int, price_type: int) -> List[int]: def write_custom_shops(rom, world, player): - shops = sorted([shop for shop in world.shops if shop.custom and shop.region.player == player], - key=lambda shop: shop.sram_offset) + shops = sorted([shop for shop in world.worlds[player].shops if shop.custom], key=lambda shop: shop.sram_offset) shop_data = bytearray() items_data = bytearray() diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index b79170dac2..a5b14e0c2d 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -147,7 +147,6 @@ def set_defeat_dungeon_boss_rule(location): add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state)) - def set_always_allow(spot, rule): spot.always_allow = rule @@ -980,18 +979,19 @@ def check_is_dark_world(region): return False -def add_conditional_lamps(world, player): +def add_conditional_lamps(multiworld, player): # Light cones in standard depend on which world we actually are in, not which one the location would normally be # We add Lamp requirements only to those locations which lie in the dark world (or everything if open + local_world = multiworld.worlds[player] def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=False): - if (not world.dark_world_light_cone and check_is_dark_world(world.get_region(region, player))) or ( - not world.light_world_light_cone and not check_is_dark_world(world.get_region(region, player))): + if (not local_world.dark_world_light_cone and check_is_dark_world(local_world.get_region(region))) or ( + not local_world.light_world_light_cone and not check_is_dark_world(local_world.get_region(region))): if spottype == 'Location': - spot = world.get_location(spot, player) + spot = local_world.get_location(spot) else: - spot = world.get_entrance(spot, player) - add_lamp_requirement(world, spot, player, accessible_torch) + spot = local_world.get_entrance(spot) + add_lamp_requirement(multiworld, spot, player, accessible_torch) add_conditional_lamp('Misery Mire (Vitreous)', 'Misery Mire (Entrance)', 'Entrance') add_conditional_lamp('Turtle Rock (Dark Room) (North)', 'Turtle Rock (Entrance)', 'Entrance') @@ -1002,7 +1002,7 @@ def add_conditional_lamps(world, player): 'Location', True) add_conditional_lamp('Palace of Darkness - Dark Basement - Right', 'Palace of Darkness (Entrance)', 'Location', True) - if world.worlds[player].options.mode != 'inverted': + if multiworld.worlds[player].options.mode != 'inverted': add_conditional_lamp('Agahnim 1', 'Agahnims Tower', 'Entrance') add_conditional_lamp('Castle Tower - Dark Maze', 'Agahnims Tower') add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Agahnims Tower') @@ -1024,10 +1024,10 @@ def add_conditional_lamps(world, player): add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True) add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True) - if not world.worlds[player].options.mode == "standard": - add_lamp_requirement(world, world.get_location('Sewers - Dark Cross', player), player) - add_lamp_requirement(world, world.get_entrance('Sewers Back Door', player), player) - add_lamp_requirement(world, world.get_entrance('Throne Room', player), player) + if not multiworld.worlds[player].options.mode == "standard": + add_lamp_requirement(multiworld, local_world.get_location("Sewers - Dark Cross"), player) + add_lamp_requirement(multiworld, local_world.get_entrance("Sewers Back Door"), player) + add_lamp_requirement(multiworld, local_world.get_entrance("Throne Room"), player) def open_rules(world, player): diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index bb3945f5b0..89e43a1a04 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -14,8 +14,6 @@ from .Items import item_name_groups from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows -logger = logging.getLogger("Shops") - @unique class ShopType(IntEnum): @@ -162,7 +160,10 @@ shop_class_mapping = {ShopType.UpgradeShop: UpgradeShop, def push_shop_inventories(multiworld): - shop_slots = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if shop.type + all_shops = [] + for world in multiworld.get_game_worlds(ALttPLocation.game): + all_shops.extend(world.shops) + shop_slots = [location for shop_locations in (shop.region.locations for shop in all_shops if shop.type != ShopType.TakeAny) for location in shop_locations if location.shop_slot is not None] for location in shop_slots: @@ -178,7 +179,7 @@ def push_shop_inventories(multiworld): get_price(multiworld, location.shop.inventory[location.shop_slot], location.player, location.shop_price_type)[1]) - for world in multiworld.get_game_worlds("A Link to the Past"): + for world in multiworld.get_game_worlds(ALttPLocation.game): world.pushed_shop_inventories.set() @@ -225,7 +226,7 @@ def create_shops(multiworld, player: int): if locked is None: shop.locked = True region.shop = shop - multiworld.shops.append(shop) + multiworld.worlds[player].shops.append(shop) for index, item in enumerate(inventory): shop.add_inventory(index, *item) if not locked and (num_slots or type == ShopType.UpgradeShop): @@ -309,50 +310,50 @@ def set_up_shops(multiworld, player: int): from .Options import small_key_shuffle # TODO: move hard+ mode changes for shields here, utilizing the new shops - if multiworld.worlds[player].options.retro_bow: + local_world = multiworld.worlds[player] + + if local_world.options.retro_bow: rss = multiworld.get_region('Red Shield Shop', player).shop + # Can't just replace the single arrow with 10 arrows as retro doesn't need them. replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50], - ['Blue Shield', 50], ['Small Heart', - 10]] # Can't just replace the single arrow with 10 arrows as retro doesn't need them. - if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal: + ['Blue Shield', 50], ['Small Heart', 10]] + if local_world.options.small_key_shuffle == small_key_shuffle.option_universal: replacement_items.append(['Small Key (Universal)', 100]) replacement_item = multiworld.random.choice(replacement_items) rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1]) rss.locked = True - if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal or multiworld.worlds[player].options.retro_bow: - for shop in multiworld.random.sample([s for s in multiworld.shops if - s.custom and not s.locked and s.type == ShopType.Shop - and s.region.player == player], 5): + if local_world.options.small_key_shuffle == small_key_shuffle.option_universal or local_world.options.retro_bow: + for shop in multiworld.random.sample([s for s in local_world.shops if + s.custom and not s.locked and s.type == ShopType.Shop], 5): shop.locked = True slots = [0, 1, 2] multiworld.random.shuffle(slots) slots = iter(slots) - if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal: + if local_world.options.small_key_shuffle == small_key_shuffle.option_universal: shop.add_inventory(next(slots), 'Small Key (Universal)', 100) - if multiworld.worlds[player].options.retro_bow: + if local_world.options.retro_bow: shop.push_inventory(next(slots), 'Single Arrow', 80) - if multiworld.worlds[player].options.shuffle_capacity_upgrades: - for shop in multiworld.shops: - if shop.type == ShopType.UpgradeShop and shop.region.player == player and \ + if local_world.options.shuffle_capacity_upgrades: + for shop in local_world.shops: + if shop.type == ShopType.UpgradeShop and \ shop.region.name == "Capacity Upgrade": shop.clear_inventory() - if (multiworld.worlds[player].options.shuffle_shop_inventories or multiworld.worlds[player].options.randomize_shop_prices - or multiworld.worlds[player].options.randomize_cost_types): + if (local_world.options.shuffle_shop_inventories or local_world.options.randomize_shop_prices + or local_world.options.randomize_cost_types): shops = [] total_inventory = [] - for shop in multiworld.shops: - if shop.region.player == player: - if shop.type == ShopType.Shop and not shop.locked: - shops.append(shop) - total_inventory.extend(shop.inventory) + for shop in local_world.shops: + if shop.type == ShopType.Shop and not shop.locked: + shops.append(shop) + total_inventory.extend(shop.inventory) for item in total_inventory: item["price_type"], item["price"] = get_price(multiworld, item, player) - if multiworld.worlds[player].options.shuffle_shop_inventories: + if local_world.options.shuffle_shop_inventories: multiworld.random.shuffle(total_inventory) i = 0 @@ -407,7 +408,7 @@ price_rate_display = { } -def get_price_modifier(item): +def get_price_modifier(item) -> float: if item.game == "A Link to the Past": if any(x in item.name for x in ['Compass', 'Map', 'Single Bomb', 'Single Arrow', 'Piece of Heart']): @@ -418,9 +419,9 @@ def get_price_modifier(item): elif any(x in item.name for x in ['Small Key', 'Heart']): return 0.5 else: - return 1 + return 1.0 if item.advancement: - return 1 + return 1.0 elif item.useful: return 0.5 else: @@ -471,7 +472,7 @@ def get_price(multiworld, item, player: int, price_type=None): def shop_price_rules(state: CollectionState, player: int, location: ALttPLocation): if location.shop_price_type == ShopPriceType.Hearts: - return has_hearts(state, player, (location.shop_price / 8) + 1) + return has_hearts(state, player, (location.shop_price // 8) + 1) elif location.shop_price_type == ShopPriceType.Bombs: return can_use_bombs(state, player, location.shop_price) elif location.shop_price_type == ShopPriceType.Arrows: diff --git a/worlds/alttp/StateHelpers.py b/worlds/alttp/StateHelpers.py index 6ac3c4b8f8..98409c8a8d 100644 --- a/worlds/alttp/StateHelpers.py +++ b/worlds/alttp/StateHelpers.py @@ -14,13 +14,13 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for - shop in state.multiworld.shops) + return any(shop.has_unlimited(item) and shop.region.can_reach(state) for + shop in state.multiworld.worlds[player].shops) def can_buy(state: CollectionState, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for - shop in state.multiworld.shops) + return any(shop.has(item) and shop.region.can_reach(state) for + shop in state.multiworld.worlds[player].shops) def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool: diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 773fd7050c..4ee5b9d266 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -236,6 +236,8 @@ class ALTTPWorld(World): required_client_version = (0, 4, 1) web = ALTTPWeb() + shops: list[Shop] + pedestal_credit_texts: typing.Dict[int, str] = \ {data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit} sickkid_credit_texts: typing.Dict[int, str] = \ @@ -282,6 +284,10 @@ class ALTTPWorld(World): clock_mode: str = "" treasure_hunt_required: int = 0 treasure_hunt_total: int = 0 + light_world_light_cone: bool = False + dark_world_light_cone: bool = False + save_and_quit_from_boss: bool = True + rupoor_cost: int = 10 def __init__(self, *args, **kwargs): self.dungeon_local_item_names = set() @@ -298,6 +304,7 @@ class ALTTPWorld(World): self.fix_trock_exit = None self.required_medallions = ["Ether", "Quake"] self.escape_assist = [] + self.shops = [] super(ALTTPWorld, self).__init__(*args, **kwargs) @classmethod @@ -800,7 +807,7 @@ class ALTTPWorld(World): return shop_data - if shop_info := [build_shop_info(shop) for shop in self.multiworld.shops if shop.custom]: + if shop_info := [build_shop_info(shop) for shop in self.shops if shop.custom]: spoiler_handle.write('\n\nShops:\n\n') for shop_data in shop_info: spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join( From 9edd55961f8918face11d0a0e22ce9b24467147c Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 2 Aug 2025 01:26:50 +0200 Subject: [PATCH 22/29] LttP: remove sprite download from setup flow & make sprite repo dynamic (#4830) --- LttPAdjuster.py | 51 +++++++++++++--------- WebHostLib/lttpsprites.py | 2 +- data/sprites/{alttpr => remote}/.gitignore | 0 inno_setup.iss | 5 --- setup.py | 8 ++-- worlds/alttp/Rom.py | 3 +- 6 files changed, 39 insertions(+), 30 deletions(-) rename data/sprites/{alttpr => remote}/.gitignore (100%) diff --git a/LttPAdjuster.py b/LttPAdjuster.py index d44f413499..4816210ff5 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past" WINDOW_MIN_HEIGHT = 525 WINDOW_MIN_WIDTH = 425 + class AdjusterWorld(object): class AdjusterSubWorld(object): def __init__(self, random): @@ -48,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): def _get_help_string(self, action): return textwrap.dedent(action.help) + # See argparse.BooleanOptionalAction class BooleanOptionalActionWithDisable(argparse.Action): def __init__(self, @@ -363,10 +365,10 @@ def run_sprite_update(): logging.info("Done updating sprites") -def update_sprites(task, on_finish=None): +def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"): resultmessage = "" successful = True - sprite_dir = user_path("data", "sprites", "alttpr") + sprite_dir = user_path("data", "sprites", "alttp", "remote") os.makedirs(sprite_dir, exist_ok=True) ctx = get_cert_none_ssl_context() @@ -376,11 +378,11 @@ def update_sprites(task, on_finish=None): on_finish(successful, resultmessage) try: - task.update_status("Downloading alttpr sprites list") - with urlopen('https://alttpr.com/sprites', context=ctx) as response: + task.update_status("Downloading remote sprites list") + with urlopen(repository_url, context=ctx) as response: sprites_arr = json.loads(response.read().decode("utf-8")) except Exception as e: - resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) + resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) successful = False task.queue_event(finished) return @@ -388,13 +390,13 @@ def update_sprites(task, on_finish=None): try: task.update_status("Determining needed sprites") current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')] - alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) + remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) for sprite in sprites_arr if sprite["author"] != "Nintendo"] - needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if + needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if filename not in current_sprites] - alttpr_filenames = [filename for (_, filename) in alttpr_sprites] - obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames] + remote_filenames = [filename for (_, filename) in remote_sprites] + obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames] except Exception as e: resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % ( type(e).__name__, e) @@ -446,7 +448,7 @@ def update_sprites(task, on_finish=None): successful = False if successful: - resultmessage = "alttpr sprites updated successfully" + resultmessage = "Remote sprites updated successfully" task.queue_event(finished) @@ -867,7 +869,7 @@ class SpriteSelector(): def open_custom_sprite_dir(_evt): open_file(self.custom_sprite_dir) - alttpr_frametitle = Label(self.window, text='ALTTPR Sprites') + remote_frametitle = Label(self.window, text='Remote Sprites') custom_frametitle = Frame(self.window) title_text = Label(custom_frametitle, text="Custom Sprites") @@ -876,8 +878,8 @@ class SpriteSelector(): title_link.pack(side=LEFT) title_link.bind("", open_custom_sprite_dir) - self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, - 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.') + self.icon_section(remote_frametitle, self.remote_sprite_dir, + 'Remote sprites not found. Click "Update remote sprites" to download them.') self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.') if not randomOnEvent: @@ -890,11 +892,18 @@ class SpriteSelector(): button = Button(frame, text="Browse for file...", command=self.browse_for_sprite) button.pack(side=RIGHT, padx=(5, 0)) - button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites) + button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites) button.pack(side=RIGHT, padx=(5, 0)) + + repository_label = Label(frame, text='Sprite Repository:') + self.repository_url = StringVar(frame, "https://alttpr.com/sprites") + repository_entry = Entry(frame, textvariable=self.repository_url) + repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1) + repository_label.pack(side=RIGHT, expand=False, padx=(0, 5)) + button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite) - button.pack(side=LEFT,padx=(0,5)) + button.pack(side=LEFT, padx=(0, 5)) button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite) button.pack(side=LEFT, padx=(0, 5)) @@ -1054,7 +1063,7 @@ class SpriteSelector(): for i, button in enumerate(frame.buttons): button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow) - def update_alttpr_sprites(self): + def update_remote_sprites(self): # need to wrap in try catch. We don't want errors getting the json or downloading the files to break us. self.window.destroy() self.parent.update() @@ -1067,7 +1076,8 @@ class SpriteSelector(): messagebox.showerror("Sprite Updater", resultmessage) SpriteSelector(self.parent, self.callback, self.adjuster) - BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish) + BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", + on_finish, self.repository_url.get()) def browse_for_sprite(self): sprite = filedialog.askopenfilename( @@ -1157,12 +1167,13 @@ class SpriteSelector(): os.makedirs(self.custom_sprite_dir) @property - def alttpr_sprite_dir(self): - return user_path("data", "sprites", "alttpr") + def remote_sprite_dir(self): + return user_path("data", "sprites", "alttp", "remote") @property def custom_sprite_dir(self): - return user_path("data", "sprites", "custom") + return user_path("data", "sprites", "alttp", "custom") + def get_image_for_sprite(sprite, gif_only: bool = False): if not sprite.valid: diff --git a/WebHostLib/lttpsprites.py b/WebHostLib/lttpsprites.py index 1b8ee4cf48..9d780b13e1 100644 --- a/WebHostLib/lttpsprites.py +++ b/WebHostLib/lttpsprites.py @@ -14,7 +14,7 @@ def update_sprites_lttp(): from LttPAdjuster import update_sprites # Target directories - input_dir = user_path("data", "sprites", "alttpr") + input_dir = user_path("data", "sprites", "alttp", "remote") output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True) diff --git a/data/sprites/alttpr/.gitignore b/data/sprites/remote/.gitignore similarity index 100% rename from data/sprites/alttpr/.gitignore rename to data/sprites/remote/.gitignore diff --git a/inno_setup.iss b/inno_setup.iss index 6f41b20496..8611c849fb 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -53,10 +53,6 @@ Name: "full"; Description: "Full installation" Name: "minimal"; Description: "Minimal installation" Name: "custom"; Description: "Custom installation"; Flags: iscustom -[Components] -Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed -Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full; - [Dirs] NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; @@ -76,7 +72,6 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc [Run] Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..." -Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/setup.py b/setup.py index 593a45f830..1808b22c62 100644 --- a/setup.py +++ b/setup.py @@ -199,9 +199,10 @@ extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else [] def remove_sprites_from_folder(folder: Path) -> None: - for file in os.listdir(folder): - if file != ".gitignore": - os.remove(folder / file) + if os.path.isdir(folder): + for file in os.listdir(folder): + if file != ".gitignore": + os.remove(folder / file) def _threaded_hash(filepath: str | Path) -> str: @@ -410,6 +411,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe): os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path)) remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr") + remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttp" / "remote") self.create_manifest() diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index 88b9485aaf..6a5792d21a 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -515,7 +515,8 @@ def _populate_sprite_table(): logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.") with concurrent.futures.ThreadPoolExecutor() as pool: - sprite_paths = [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')] + sprite_paths = [user_path("data", "sprites", "alttp", "remote"), + user_path("data", "sprites", "alttp", "custom")] for dir in [dir for dir in sprite_paths if os.path.isdir(dir)]: for file in os.listdir(dir): pool.submit(load_sprite_from_file, os.path.join(dir, file)) From 277f21db7af0ca171f9faf4980ddc6db64a7e100 Mon Sep 17 00:00:00 2001 From: t3hf1gm3nt <59876300+t3hf1gm3nt@users.noreply.github.com> Date: Sat, 2 Aug 2025 13:14:24 -0400 Subject: [PATCH 23/29] The Legend of Zelda: Stepping Down as Maintainer (#5277) --- docs/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 85b31683aa..889d51415c 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -200,7 +200,7 @@ /worlds/timespinner/ @Jarno458 # The Legend of Zelda (1) -/worlds/tloz/ @Rosalie-A @t3hf1gm3nt +/worlds/tloz/ @Rosalie-A # TUNIC /worlds/tunic/ @silent-destroyer @ScipioWright From 72ae076ce7800d93caefbd905759bfa6c54f97d7 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 2 Aug 2025 21:12:58 +0200 Subject: [PATCH 24/29] WebHost: server render remaining markdown using mistune (#5276) --------- Co-authored-by: Aaron Wagener Co-authored-by: qwint Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- BaseClasses.py | 2 +- WebHost.py | 48 ++----- WebHostLib/misc.py | 149 ++++++++++++++------ WebHostLib/requirements.txt | 3 +- WebHostLib/static/assets/gameInfo.js | 45 ------ WebHostLib/static/assets/tutorial.js | 52 ------- WebHostLib/static/assets/tutorialLanding.js | 81 ----------- WebHostLib/templates/gameInfo.html | 17 --- WebHostLib/templates/markdown_document.html | 3 +- WebHostLib/templates/tutorial.html | 17 --- WebHostLib/templates/tutorialLanding.html | 32 ++++- test/webhost/test_docs.py | 32 ++--- test/webhost/test_file_generation.py | 5 - worlds/ahit/__init__.py | 2 +- worlds/osrs/__init__.py | 2 +- worlds/yugioh06/__init__.py | 2 +- 16 files changed, 157 insertions(+), 335 deletions(-) delete mode 100644 WebHostLib/static/assets/gameInfo.js delete mode 100644 WebHostLib/static/assets/tutorial.js delete mode 100644 WebHostLib/static/assets/tutorialLanding.js delete mode 100644 WebHostLib/templates/gameInfo.html delete mode 100644 WebHostLib/templates/tutorial.html diff --git a/BaseClasses.py b/BaseClasses.py index a9477a0312..d00c6007e1 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1926,7 +1926,7 @@ class Tutorial(NamedTuple): description: str language: str file_name: str - link: str + link: str # unused authors: List[str] diff --git a/WebHost.py b/WebHost.py index 768eeb5122..946eaa116f 100644 --- a/WebHost.py +++ b/WebHost.py @@ -54,16 +54,15 @@ def get_app() -> "Flask": return app -def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]: - import json +def copy_tutorials_files_to_static() -> None: import shutil import zipfile + from werkzeug.utils import secure_filename zfile: zipfile.ZipInfo from worlds.AutoWorld import AutoWorldRegister worlds = {} - data = [] for game, world in AutoWorldRegister.world_types.items(): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): worlds[game] = world @@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] shutil.rmtree(base_target_path, ignore_errors=True) for game, world in worlds.items(): # copy files from world's docs folder to the generated folder - target_path = os.path.join(base_target_path, get_file_safe_name(game)) + target_path = os.path.join(base_target_path, secure_filename(game)) os.makedirs(target_path, exist_ok=True) if world.zip_path: @@ -85,45 +84,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] for zfile in zf.infolist(): if not zfile.is_dir() and "/docs/" in zfile.filename: zfile.filename = os.path.basename(zfile.filename) - zf.extract(zfile, target_path) + with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f: + f.write(zf.read(zfile)) else: source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") files = os.listdir(source_path) for file in files: - shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file)) - - # build a json tutorial dict per game - game_data = {'gameTitle': game, 'tutorials': []} - for tutorial in world.web.tutorials: - # build dict for the json file - current_tutorial = { - 'name': tutorial.tutorial_name, - 'description': tutorial.description, - 'files': [{ - 'language': tutorial.language, - 'filename': game + '/' + tutorial.file_name, - 'link': f'{game}/{tutorial.link}', - 'authors': tutorial.authors - }] - } - - # check if the name of the current guide exists already - for guide in game_data['tutorials']: - if guide and tutorial.tutorial_name == guide['name']: - guide['files'].append(current_tutorial['files'][0]) - break - else: - game_data['tutorials'].append(current_tutorial) - - data.append(game_data) - with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target: - generic_data = {} - for games in data: - if 'Archipelago' in games['gameTitle']: - generic_data = data.pop(data.index(games)) - sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"]) - json.dump(sorted_data, json_target, indent=2, ensure_ascii=False) - return sorted_data + shutil.copyfile(Utils.local_path(source_path, file), + Utils.local_path(target_path, secure_filename(file))) if __name__ == "__main__": @@ -142,7 +110,7 @@ if __name__ == "__main__": logging.warning("Could not update LttP sprites.") app = get_app() create_options_files() - create_ordered_tutorials_file() + copy_tutorials_files_to_static() if app.config["SELFLAUNCH"]: autohost(app.config) if app.config["SELFGEN"]: diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 98731b65bd..d7ac950817 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -7,17 +7,69 @@ from flask import request, redirect, url_for, render_template, Response, session from pony.orm import count, commit, db_session from werkzeug.utils import secure_filename -from worlds.AutoWorld import AutoWorldRegister +from worlds.AutoWorld import AutoWorldRegister, World from . import app, cache from .models import Seed, Room, Command, UUID, uuid4 +from Utils import title_sorted -def get_world_theme(game_name: str): +def get_world_theme(game_name: str) -> str: if game_name in AutoWorldRegister.world_types: return AutoWorldRegister.world_types[game_name].web.theme return 'grass' +def get_visible_worlds() -> dict[str, type(World)]: + worlds = {} + for game, world in AutoWorldRegister.world_types.items(): + if not world.hidden: + worlds[game] = world + return worlds + + +def render_markdown(path: str) -> str: + import mistune + from collections import Counter + + markdown = mistune.create_markdown( + escape=False, + plugins=[ + "strikethrough", + "footnotes", + "table", + "speedup", + ], + ) + + heading_id_count: Counter[str] = Counter() + + def heading_id(text: str) -> str: + nonlocal heading_id_count + import re # there is no good way to do this without regex + + s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-") + n = heading_id_count[s] + heading_id_count[s] += 1 + if n > 0: + s += f"-{n}" + return s + + def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None: + for tok in state.tokens: + if tok["type"] == "heading" and tok["attrs"]["level"] < 4: + text = tok["text"] + assert isinstance(text, str) + unique_id = heading_id(text) + tok["attrs"]["id"] = unique_id + tok["text"] = f"{text}" # make header link to itself + + markdown.before_render_hooks.append(id_hook) + + with open(path, encoding="utf-8-sig") as f: + document = f.read() + return markdown(document) + + @app.errorhandler(404) @app.errorhandler(jinja2.exceptions.TemplateNotFound) def page_not_found(err): @@ -31,83 +83,88 @@ def start_playing(): return render_template(f"startPlaying.html") -# Game Info Pages @app.route('/games//info/') @cache.cached() def game_info(game, lang): - try: - world = AutoWorldRegister.world_types[game] - if lang not in world.web.game_info_languages: - raise KeyError("Sorry, this game's info page is not available in that language yet.") - except KeyError: - return abort(404) - return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) + """Game Info Pages""" + theme = get_world_theme(game) + secure_game_name = secure_filename(game) + lang = secure_filename(lang) + document = render_markdown(os.path.join( + app.static_folder, "generated", "docs", + secure_game_name, f"{lang}_{secure_game_name}.md" + )) + return render_template( + "markdown_document.html", + title=f"{game} Guide", + html_from_markdown=document, + theme=theme, + ) -# List of supported games @app.route('/games') @cache.cached() def games(): - worlds = {} - for game, world in AutoWorldRegister.world_types.items(): - if not world.hidden: - worlds[game] = world - return render_template("supportedGames.html", worlds=worlds) + """List of supported games""" + return render_template("supportedGames.html", worlds=get_visible_worlds()) -@app.route('/tutorial///') +@app.route('/tutorial//') @cache.cached() -def tutorial(game, file, lang): - try: - world = AutoWorldRegister.world_types[game] - if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]: - raise KeyError("Sorry, the tutorial is not available in that language yet.") - except KeyError: - return abort(404) - return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) +def tutorial(game: str, file: str): + theme = get_world_theme(game) + secure_game_name = secure_filename(game) + file = secure_filename(file) + document = render_markdown(os.path.join( + app.static_folder, "generated", "docs", + secure_game_name, file+".md" + )) + return render_template( + "markdown_document.html", + title=f"{game} Guide", + html_from_markdown=document, + theme=theme, + ) @app.route('/tutorial/') @cache.cached() def tutorial_landing(): - return render_template("tutorialLanding.html") + tutorials = {} + worlds = AutoWorldRegister.world_types + for world_name, world_type in worlds.items(): + current_world = tutorials[world_name] = {} + for tutorial in world_type.web.tutorials: + current_tutorial = current_world.setdefault(tutorial.tutorial_name, { + "description": tutorial.description, "files": {}}) + current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = { + "authors": tutorial.authors, + "language": tutorial.language + } + tutorials = {world_name: tutorials for world_name, tutorials in title_sorted( + tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)} + return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials) @app.route('/faq//') @cache.cached() def faq(lang: str): - import markdown - with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f: - document = f.read() + document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) return render_template( "markdown_document.html", title="Frequently Asked Questions", - html_from_markdown=markdown.markdown( - document, - extensions=["toc", "mdx_breakless_lists"], - extension_configs={ - "toc": {"anchorlink": True} - } - ), + html_from_markdown=document, ) @app.route('/glossary//') @cache.cached() def glossary(lang: str): - import markdown - with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f: - document = f.read() + document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) return render_template( "markdown_document.html", title="Glossary", - html_from_markdown=markdown.markdown( - document, - extensions=["toc", "mdx_breakless_lists"], - extension_configs={ - "toc": {"anchorlink": True} - } - ), + html_from_markdown=document, ) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 4e6bf25df0..8fd6dc6304 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -7,6 +7,5 @@ Flask-Compress>=1.17 Flask-Limiter>=3.12 bokeh>=3.6.3 markupsafe>=3.0.2 -Markdown>=3.7 -mdx-breakless-lists>=1.0.1 setproctitle>=1.3.5 +mistune>=3.1.3 diff --git a/WebHostLib/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js deleted file mode 100644 index 797c9f6448..0000000000 --- a/WebHostLib/static/assets/gameInfo.js +++ /dev/null @@ -1,45 +0,0 @@ -window.addEventListener('load', () => { - const gameInfo = document.getElementById('game-info'); - new Promise((resolve, reject) => { - const ajax = new XMLHttpRequest(); - ajax.onreadystatechange = () => { - if (ajax.readyState !== 4) { return; } - if (ajax.status === 404) { - reject("Sorry, this game's info page is not available in that language yet."); - return; - } - if (ajax.status !== 200) { - reject("Something went wrong while loading the info page."); - return; - } - resolve(ajax.responseText); - }; - ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` + - `${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true); - ajax.send(); - }).then((results) => { - // Populate page with HTML generated from markdown - showdown.setOption('tables', true); - showdown.setOption('strikethrough', true); - showdown.setOption('literalMidWordUnderscores', true); - gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results); - - // Reset the id of all header divs to something nicer - for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { - const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); - header.setAttribute('id', headerId); - header.addEventListener('click', () => { - window.location.hash = `#${headerId}`; - header.scrollIntoView(); - }); - } - - // Manually scroll the user to the appropriate header if anchor navigation is used - document.fonts.ready.finally(() => { - if (window.location.hash) { - const scrollTarget = document.getElementById(window.location.hash.substring(1)); - scrollTarget?.scrollIntoView(); - } - }); - }); -}); diff --git a/WebHostLib/static/assets/tutorial.js b/WebHostLib/static/assets/tutorial.js deleted file mode 100644 index c9022719fb..0000000000 --- a/WebHostLib/static/assets/tutorial.js +++ /dev/null @@ -1,52 +0,0 @@ -window.addEventListener('load', () => { - const tutorialWrapper = document.getElementById('tutorial-wrapper'); - new Promise((resolve, reject) => { - const ajax = new XMLHttpRequest(); - ajax.onreadystatechange = () => { - if (ajax.readyState !== 4) { return; } - if (ajax.status === 404) { - reject("Sorry, the tutorial is not available in that language yet."); - return; - } - if (ajax.status !== 200) { - reject("Something went wrong while loading the tutorial."); - return; - } - resolve(ajax.responseText); - }; - ajax.open('GET', `${window.location.origin}/static/generated/docs/` + - `${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` + - `${tutorialWrapper.getAttribute('data-lang')}.md`, true); - ajax.send(); - }).then((results) => { - // Populate page with HTML generated from markdown - showdown.setOption('tables', true); - showdown.setOption('strikethrough', true); - showdown.setOption('literalMidWordUnderscores', true); - showdown.setOption('disableForced4SpacesIndentedSublists', true); - tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); - - const title = document.querySelector('h1') - if (title) { - document.title = title.textContent; - } - - // Reset the id of all header divs to something nicer - for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { - const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); - header.setAttribute('id', headerId); - header.addEventListener('click', () => { - window.location.hash = `#${headerId}`; - header.scrollIntoView(); - }); - } - - // Manually scroll the user to the appropriate header if anchor navigation is used - document.fonts.ready.finally(() => { - if (window.location.hash) { - const scrollTarget = document.getElementById(window.location.hash.substring(1)); - scrollTarget?.scrollIntoView(); - } - }); - }); -}); diff --git a/WebHostLib/static/assets/tutorialLanding.js b/WebHostLib/static/assets/tutorialLanding.js deleted file mode 100644 index b820cc3465..0000000000 --- a/WebHostLib/static/assets/tutorialLanding.js +++ /dev/null @@ -1,81 +0,0 @@ -const showError = () => { - const tutorial = document.getElementById('tutorial-landing'); - document.getElementById('page-title').innerText = 'This page is out of logic!'; - tutorial.removeChild(document.getElementById('loading')); - const userMessage = document.createElement('h3'); - const homepageLink = document.createElement('a'); - homepageLink.innerText = 'Click here'; - homepageLink.setAttribute('href', '/'); - userMessage.append(homepageLink); - userMessage.append(' to go back to safety!'); - tutorial.append(userMessage); -}; - -window.addEventListener('load', () => { - const ajax = new XMLHttpRequest(); - ajax.onreadystatechange = () => { - if (ajax.readyState !== 4) { return; } - const tutorialDiv = document.getElementById('tutorial-landing'); - if (ajax.status !== 200) { return showError(); } - - try { - const games = JSON.parse(ajax.responseText); - games.forEach((game) => { - const gameTitle = document.createElement('h2'); - gameTitle.innerText = game.gameTitle; - gameTitle.id = `${encodeURIComponent(game.gameTitle)}`; - tutorialDiv.appendChild(gameTitle); - - game.tutorials.forEach((tutorial) => { - const tutorialName = document.createElement('h3'); - tutorialName.innerText = tutorial.name; - tutorialDiv.appendChild(tutorialName); - - const tutorialDescription = document.createElement('p'); - tutorialDescription.innerText = tutorial.description; - tutorialDiv.appendChild(tutorialDescription); - - const intro = document.createElement('p'); - intro.innerText = 'This guide is available in the following languages:'; - tutorialDiv.appendChild(intro); - - const fileList = document.createElement('ul'); - tutorial.files.forEach((file) => { - const listItem = document.createElement('li'); - const anchor = document.createElement('a'); - anchor.innerText = file.language; - anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`); - listItem.appendChild(anchor); - - listItem.append(' by '); - for (let author of file.authors) { - listItem.append(author); - if (file.authors.indexOf(author) !== (file.authors.length -1)) { - listItem.append(', '); - } - } - - fileList.appendChild(listItem); - }); - tutorialDiv.appendChild(fileList); - }); - }); - - tutorialDiv.removeChild(document.getElementById('loading')); - } catch (error) { - showError(); - console.error(error); - } - - // Check if we are on an anchor when coming in, and scroll to it. - const hash = window.location.hash; - if (hash) { - const offset = 128; // To account for navbar banner at top of page. - window.scrollTo(0, 0); - const rect = document.getElementById(hash.slice(1)).getBoundingClientRect(); - window.scrollTo(rect.left, rect.top - offset); - } - }; - ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true); - ajax.send(); -}); diff --git a/WebHostLib/templates/gameInfo.html b/WebHostLib/templates/gameInfo.html deleted file mode 100644 index 3b908004b1..0000000000 --- a/WebHostLib/templates/gameInfo.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'pageWrapper.html' %} - -{% block head %} - {{ game }} Info - - - -{% endblock %} - -{% block body %} - {% include 'header/'+theme+'Header.html' %} -
- -
-{% endblock %} diff --git a/WebHostLib/templates/markdown_document.html b/WebHostLib/templates/markdown_document.html index 07b3c8354d..a56ea244c7 100644 --- a/WebHostLib/templates/markdown_document.html +++ b/WebHostLib/templates/markdown_document.html @@ -1,7 +1,8 @@ {% extends 'pageWrapper.html' %} {% block head %} - {% include 'header/grassHeader.html' %} + {% set theme_name = theme|default("grass", true) %} + {% include "header/"+theme_name+"Header.html" %} {{ title }} {% endblock %} diff --git a/WebHostLib/templates/tutorial.html b/WebHostLib/templates/tutorial.html deleted file mode 100644 index 4b6622c313..0000000000 --- a/WebHostLib/templates/tutorial.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'pageWrapper.html' %} - -{% block head %} - {% include 'header/'+theme+'Header.html' %} - Archipelago - - - -{% endblock %} - -{% block body %} -
- -
-{% endblock %} diff --git a/WebHostLib/templates/tutorialLanding.html b/WebHostLib/templates/tutorialLanding.html index 14db577e77..a96da883b6 100644 --- a/WebHostLib/templates/tutorialLanding.html +++ b/WebHostLib/templates/tutorialLanding.html @@ -3,14 +3,32 @@ {% block head %} {% include 'header/grassHeader.html' %} Archipelago Guides - - - + + {% endblock %} {% block body %} -
-

Archipelago Guides

-

Loading...

+
+

Archipelago Guides

+ {% for world_name, world_type in worlds.items() %} +

{{ world_type.game }}

+ {% for tutorial_name, tutorial_data in tutorials[world_name].items() %} +

{{ tutorial_name }}

+

{{ tutorial_data.description }}

+

This guide is available in the following languages:

+
    + {% for file_name, file_data in tutorial_data.files.items() %} +
  • + {{ file_data.language }} + by + {% for author in file_data.authors %} + {{ author }} + {% if not loop.last %}, {% endif %} + {% endfor %} +
  • + {% endfor %} +
+ {% endfor %} + {% endfor %}
-{% endblock %} +{% endblock %} diff --git a/test/webhost/test_docs.py b/test/webhost/test_docs.py index 1e6c1b88f4..a178a7cbf0 100644 --- a/test/webhost/test_docs.py +++ b/test/webhost/test_docs.py @@ -2,6 +2,8 @@ import unittest import Utils import os +from werkzeug.utils import secure_filename + import WebHost from worlds.AutoWorld import AutoWorldRegister @@ -9,36 +11,30 @@ from worlds.AutoWorld import AutoWorldRegister class TestDocs(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.tutorials_data = WebHost.create_ordered_tutorials_file() + WebHost.copy_tutorials_files_to_static() def test_has_tutorial(self): - games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data) for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: with self.subTest(game_name): - try: - self.assertIn(game_name, games_with_tutorial) - except AssertionError: - # look for partial name in the tutorial name - for game in games_with_tutorial: - if game_name in game: - break - else: - self.fail(f"{game_name} has no setup tutorial. " - f"Games with Tutorial: {games_with_tutorial}") + tutorials = world_type.web.tutorials + self.assertGreater(len(tutorials), 0, msg=f"{game_name} has no setup tutorial.") + + safe_name = secure_filename(game_name) + target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name) + for tutorial in tutorials: + self.assertTrue( + os.path.isfile(Utils.local_path(target_path, secure_filename(tutorial.file_name))), + f'{game_name} missing tutorial file {tutorial.file_name}.' + ) def test_has_game_info(self): for game_name, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: - safe_name = Utils.get_file_safe_name(game_name) + safe_name = secure_filename(game_name) target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name) for game_info_lang in world_type.web.game_info_languages: with self.subTest(game_name): - self.assertTrue( - safe_name == game_name or - not os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{game_name}.md')), - f'Info docs have be named _{safe_name}.md for {game_name}.' - ) self.assertTrue( os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{safe_name}.md')), f'{game_name} missing game info file for "{game_info_lang}" language.' diff --git a/test/webhost/test_file_generation.py b/test/webhost/test_file_generation.py index 059f6b49a1..7b14ac871b 100644 --- a/test/webhost/test_file_generation.py +++ b/test/webhost/test_file_generation.py @@ -29,8 +29,3 @@ class TestFileGeneration(unittest.TestCase): with open(file, encoding="utf-8-sig") as f: for value in roll_options({file.name: f.read()})[0].values(): self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.") - - def test_tutorial(self): - WebHost.create_ordered_tutorials_file() - self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json"))) - self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json"))) diff --git a/worlds/ahit/__init__.py b/worlds/ahit/__init__.py index d258f8050d..ff11728372 100644 --- a/worlds/ahit/__init__.py +++ b/worlds/ahit/__init__.py @@ -34,7 +34,7 @@ class AWebInTime(WebWorld): "Multiworld Setup Guide", "A guide for setting up A Hat in Time to be played in Archipelago.", "English", - "ahit_en.md", + "setup_en.md", "setup/en", ["CookieCat"] )] diff --git a/worlds/osrs/__init__.py b/worlds/osrs/__init__.py index 9e439fe52c..a54e272d05 100644 --- a/worlds/osrs/__init__.py +++ b/worlds/osrs/__init__.py @@ -25,7 +25,7 @@ class OSRSWeb(WebWorld): "Multiworld Setup Guide", "A guide to setting up the Old School Runescape Randomizer connected to an Archipelago Multiworld", "English", - "docs/setup_en.md", + "setup_en.md", "setup/en", ["digiholic"] ) diff --git a/worlds/yugioh06/__init__.py b/worlds/yugioh06/__init__.py index 9070683f33..5d4cddd95c 100644 --- a/worlds/yugioh06/__init__.py +++ b/worlds/yugioh06/__init__.py @@ -56,7 +56,7 @@ class Yugioh06Web(WebWorld): "A guide to setting up Yu-Gi-Oh! - Ultimate Masters Edition - World Championship Tournament 2006 " "for Archipelago on your computer.", "English", - "docs/setup_en.md", + "setup_en.md", "setup/en", ["Rensen"], ) From d408f7cabcf17301d722779fe83fe8b84ae07089 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 2 Aug 2025 21:19:23 +0200 Subject: [PATCH 25/29] Subnautica: add empty tanks option (#5271) --- worlds/subnautica/__init__.py | 3 ++- worlds/subnautica/options.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 850c23c7dd..bfedf0c90a 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -41,7 +41,7 @@ class SubnauticaWorld(World): location_name_to_id = all_locations options_dataclass = options.SubnauticaOptions options: options.SubnauticaOptions - required_client_version = (0, 5, 0) + required_client_version = (0, 6, 2) origin_region_name = "Planet 4546B" creatures_to_scan: List[str] @@ -155,6 +155,7 @@ class SubnauticaWorld(World): "creatures_to_scan": self.creatures_to_scan, "death_link": self.options.death_link.value, "free_samples": self.options.free_samples.value, + "empty_tanks": self.options.empty_tanks.value, } return slot_data diff --git a/worlds/subnautica/options.py b/worlds/subnautica/options.py index 6cdcb33d89..0aa869de0b 100644 --- a/worlds/subnautica/options.py +++ b/worlds/subnautica/options.py @@ -129,6 +129,10 @@ class FillerItemsDistribution(ItemDict): return list(self.value.keys()), list(accumulate(self.value.values())) +class EmptyTanks(DefaultOnToggle): + """Oxygen Tanks stored in inventory are empty if enabled.""" + + @dataclass class SubnauticaOptions(PerGameCommonOptions): swim_rule: SwimRule @@ -140,3 +144,4 @@ class SubnauticaOptions(PerGameCommonOptions): death_link: SubnauticaDeathLink start_inventory_from_pool: StartInventoryPool filler_items_distribution: FillerItemsDistribution + empty_tanks: EmptyTanks From 84c2d70d9ada50d212d0662e07e0660c0e78b63d Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:50:59 -0400 Subject: [PATCH 26/29] Fix regression on 404 redirects --- WebHostLib/misc.py | 58 +++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index d7ac950817..ee85d3defb 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -87,19 +87,22 @@ def start_playing(): @cache.cached() def game_info(game, lang): """Game Info Pages""" - theme = get_world_theme(game) - secure_game_name = secure_filename(game) - lang = secure_filename(lang) - document = render_markdown(os.path.join( - app.static_folder, "generated", "docs", - secure_game_name, f"{lang}_{secure_game_name}.md" - )) - return render_template( - "markdown_document.html", - title=f"{game} Guide", - html_from_markdown=document, - theme=theme, - ) + try: + theme = get_world_theme(game) + secure_game_name = secure_filename(game) + lang = secure_filename(lang) + document = render_markdown(os.path.join( + app.static_folder, "generated", "docs", + secure_game_name, f"{lang}_{secure_game_name}.md" + )) + return render_template( + "markdown_document.html", + title=f"{game} Guide", + html_from_markdown=document, + theme=theme, + ) + except FileNotFoundError: + return abort(404) @app.route('/games') @@ -112,19 +115,22 @@ def games(): @app.route('/tutorial//') @cache.cached() def tutorial(game: str, file: str): - theme = get_world_theme(game) - secure_game_name = secure_filename(game) - file = secure_filename(file) - document = render_markdown(os.path.join( - app.static_folder, "generated", "docs", - secure_game_name, file+".md" - )) - return render_template( - "markdown_document.html", - title=f"{game} Guide", - html_from_markdown=document, - theme=theme, - ) + try: + theme = get_world_theme(game) + secure_game_name = secure_filename(game) + file = secure_filename(file) + document = render_markdown(os.path.join( + app.static_folder, "generated", "docs", + secure_game_name, file+".md" + )) + return render_template( + "markdown_document.html", + title=f"{game} Guide", + html_from_markdown=document, + theme=theme, + ) + except FileNotFoundError: + return abort(404) @app.route('/tutorial/') From e6d2d8f4557b382676ca038e264d5d1a69b2682a Mon Sep 17 00:00:00 2001 From: Ishigh1 Date: Mon, 4 Aug 2025 14:19:51 +0200 Subject: [PATCH 27/29] Core: Added a leading 0 to classification.as_flag #5291 --- BaseClasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index d00c6007e1..93264d909c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1571,7 +1571,7 @@ class ItemClassification(IntFlag): def as_flag(self) -> int: """As Network API flag int.""" - return int(self & 0b0111) + return int(self & 0b00111) class Item: From 3b88630b0d8a58aeb3f58a2cb3957efc7b1ace97 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 4 Aug 2025 08:21:58 -0400 Subject: [PATCH 28/29] TUNIC: Fix zig skip showing up in decoupled + fixed shop #5289 --- worlds/tunic/er_scripts.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index ae1b5fcb45..9fc44d842c 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -255,8 +255,10 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal else: dead_ends.append(portal) dead_end_direction_tracker[portal.direction] += 1 - if portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop: + if (portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop + and not decoupled): # direction isn't meaningful here since zig skip cannot be in direction pairs mode + # don't add it in decoupled two_plus.append(portal) # now we generate the shops and add them to the dead ends list From 4e92cac171e3c35688cae74f65a812aa17b0035a Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Mon, 4 Aug 2025 11:46:05 -0400 Subject: [PATCH 29/29] LADX: Update Docs (#5290) * convert ladxr section to markdown, other adjustments make links clickable crow icon -> open tracker adjust for removed sprite sheets some adjustments in ladxr section for differences in the ap version: we don't have a casual logic we don't have stealing options * fix link, and another correction --- worlds/ladx/docs/en_Links Awakening DX.md | 91 +++++++++++++---------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/worlds/ladx/docs/en_Links Awakening DX.md b/worlds/ladx/docs/en_Links Awakening DX.md index 91a34107c1..ad691008ec 100644 --- a/worlds/ladx/docs/en_Links Awakening DX.md +++ b/worlds/ladx/docs/en_Links Awakening DX.md @@ -34,62 +34,75 @@ business! ## I don't know what to do! -That's not a question - but I'd suggest clicking the crow icon on your client, which will load an AP compatible autotracker for LADXR. +That's not a question - but I'd suggest clicking the **Open Tracker** button in your client, which will load an AP compatible autotracker for LADXR. ## What is this randomizer based on? -This randomizer is based on (forked from) the wonderful work daid did on LADXR - https://github.com/daid/LADXR +This randomizer is based on (forked from) the wonderful work daid did on [LADXR](https://github.com/daid/LADXR) -The autotracker code for communication with magpie tracker is directly copied from kbranch's repo - https://github.com/kbranch/Magpie/tree/master/autotracking +The autotracker code for communication with magpie tracker is directly copied from [kbranch's repo](https://github.com/kbranch/Magpie) ### Graphics The following sprite sheets have been included with permission of their respective authors: -* by Madam Materia (https://www.twitch.tv/isabelle_zephyr) +* by [Madam Materia](https://www.twitch.tv/isabelle_zephyr) * Matty_LA -* by Linker (https://twitter.com/BenjaminMaksym) - * Bowwow - * Bunny - * Luigi - * Mario - * Richard - * Tarin -Title screen graphics by toomanyteeth✨ (https://instagram.com/toomanyyyteeth) +Title screen graphics by [toomanyteeth✨](https://instagram.com/toomanyyyteeth) ## Some tips from LADXR... -

Locations

-

All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed.

-

The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations.

-

Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5.

+### Locations -

Color Dungeon

-

The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items.

-

To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right.

+All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed. -

Bowwow

-

Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else.

+The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations. -

Added things

-

In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando).

-

If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out.

-

The flying rooster is (optionally) available as an item.

-

You can access the Bird Key cave item with the L2 Power Bracelet.

-

Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool.

-

Your inventory has been increased by four, to accommodate these items now coexisting with eachother.

+Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5. -

Removed things

-

The ghost mini-quest after D4 never shows up, his seashell reward is always available.

-

The walrus is moved a bit, so that you can access the desert without taking Marin on a date.

+### Color Dungeon -

Logic

-

Depending on your options, you can only steal after you find the sword, always, or never.

-

Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers.

-

Killing enemies with bombs is in normal logic. You can switch to casual logic if you do not want this.

-

D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet.

+The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items. -

Tech

-

The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang.

-

The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.

+To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right. + +### Bowwow + +Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else. + +### Added things + +In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando). + +If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out. + +The flying rooster is (optionally) available as an item. + +If the rooster is disabled, you can access the Bird Key cave item with the L2 Power Bracelet. + +Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool. + +Your inventory has been increased by four, to accommodate these items now coexisting with eachother. + +### Removed things + +The ghost mini-quest after D4 never shows up, his seashell reward is always available. + +The walrus is moved a bit, so that you can access the desert without taking Marin on a date. + +### Logic + +You can only steal after you find the sword. + +Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers. + +Killing enemies with bombs is in logic. + +D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet. + +### Tech + +The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang. + +The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.