From 24471ceff1454df347c95f28fde0c1bb73adcb6c Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 2 Sep 2022 16:37:23 -0500 Subject: [PATCH 001/163] map option objects to a `World.options` dict --- BaseClasses.py | 3 ++- worlds/AutoWorld.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index cea1d48e6f07..7609875e4b34 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -204,10 +204,11 @@ def set_options(self, args): for player in self.player_ids: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] + self.worlds[player] = world_type(self, player) for option_key in world_type.option_definitions: setattr(self, option_key, getattr(args, option_key, {})) + self.worlds[player].options[option_key] = getattr(args, option_key)[player] - self.worlds[player] = world_type(self, player) def set_item_links(self): item_links = {} diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 8d9a1b08299b..24d08c70932e 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -121,6 +121,7 @@ class World(metaclass=AutoWorldRegister): A Game should have its own subclass of World in which it defines the required data structures.""" option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping + options: Dict[str, Option[Any]] = {} # option names to resulting option object game: str # name the game topology_present: bool = False # indicate if world type has any meaningful layout/pathing From 220571f8c045e48374de7f3f49f1d670562c314b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 2 Sep 2022 16:37:37 -0500 Subject: [PATCH 002/163] convert RoR2 to options dict system for testing --- worlds/ror2/__init__.py | 81 ++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index af65a15ea4c5..bcc9f174dc0e 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -42,18 +42,17 @@ class RiskOfRainWorld(World): def generate_early(self) -> None: # figure out how many revivals should exist in the pool - self.total_revivals = int(self.world.total_revivals[self.player].value / 100 * - self.world.total_locations[self.player].value) + self.total_revivals = int(self.options["total_revivals"].value // 100 * self.options["total_locations"].value) def generate_basic(self) -> None: # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend - if self.world.start_with_revive[self.player].value: + if self.options["start_with_revive"].value: self.world.push_precollected(self.world.create_item("Dio's Best Friend", self.player)) # if presets are enabled generate junk_pool from the selected preset - pool_option = self.world.item_weights[self.player].value + pool_option = self.options["item_weights"].value junk_pool: Dict[str, int] = {} - if self.world.item_pool_presets[self.player]: + if self.options["item_pool_presets"]: # generate chaos weights if the preset is chosen if pool_option == ItemWeights.option_chaos: for name, max_value in item_pool_weights[pool_option].items(): @@ -62,20 +61,20 @@ def generate_basic(self) -> None: junk_pool = item_pool_weights[pool_option].copy() else: # generate junk pool from user created presets junk_pool = { - "Item Scrap, Green": self.world.green_scrap[self.player].value, - "Item Scrap, Red": self.world.red_scrap[self.player].value, - "Item Scrap, Yellow": self.world.yellow_scrap[self.player].value, - "Item Scrap, White": self.world.white_scrap[self.player].value, - "Common Item": self.world.common_item[self.player].value, - "Uncommon Item": self.world.uncommon_item[self.player].value, - "Legendary Item": self.world.legendary_item[self.player].value, - "Boss Item": self.world.boss_item[self.player].value, - "Lunar Item": self.world.lunar_item[self.player].value, - "Equipment": self.world.equipment[self.player].value + "Item Scrap, Green": self.options["green_scrap"].value, + "Item Scrap, Red": self.options["red_scrap"].value, + "Item Scrap, Yellow": self.options["yellow_scrap"].value, + "Item Scrap, White": self.options["white_scrap"].value, + "Common Item": self.options["common_item"].value, + "Uncommon Item": self.options["uncommon_item"].value, + "Legendary Item": self.options["legendary_item"].value, + "Boss Item": self.options["boss_item"].value, + "Lunar Item": self.options["lunar_item"].value, + "Equipment": self.options["equipment"].value } # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled - if not (self.world.enable_lunar[self.player] or pool_option == ItemWeights.option_lunartic): + if not (self.options["enable_lunar"] or pool_option == ItemWeights.option_lunartic): junk_pool.pop("Lunar Item") # Generate item pool @@ -85,7 +84,7 @@ def generate_basic(self) -> None: # Fill remaining items with randomly generated junk itempool += self.world.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), - k=self.world.total_locations[self.player].value - self.total_revivals) + k=self.options["total_locations"].value - self.total_revivals) # Convert itempool into real items itempool = list(map(lambda name: self.create_item(name), itempool)) @@ -98,7 +97,7 @@ def set_rules(self) -> None: def create_regions(self) -> None: menu = create_region(self.world, self.player, "Menu") petrichor = create_region(self.world, self.player, "Petrichor V", - [f"ItemPickup{i + 1}" for i in range(self.world.total_locations[self.player].value)]) + [f"ItemPickup{i + 1}" for i in range(self.options["total_locations"].value)]) connection = Entrance(self.player, "Lobby", menu) menu.exits.append(connection) @@ -106,16 +105,16 @@ def create_regions(self) -> None: self.world.regions += [menu, petrichor] - create_events(self.world, self.player) + self.create_events() def fill_slot_data(self): return { - "itemPickupStep": self.world.item_pickup_step[self.player].value, + "itemPickupStep": self.options["item_pickup_step"].value, "seed": "".join(self.world.slot_seeds[self.player].choice(string.digits) for _ in range(16)), - "totalLocations": self.world.total_locations[self.player].value, - "totalRevivals": self.world.total_revivals[self.player].value, - "startWithDio": self.world.start_with_revive[self.player].value, - "FinalStageDeath": self.world.final_stage_death[self.player].value + "totalLocations": self.options["total_locations"].value, + "totalRevivals": self.options["total_revivals"].value, + "startWithDio": self.options["start_with_revive"].value, + "FinalStageDeath": self.options["final_stage_death"].value } def create_item(self, name: str) -> Item: @@ -129,23 +128,23 @@ def create_item(self, name: str) -> Item: item = RiskOfRainItem(name, classification, item_id, self.player) return item - -def create_events(world: MultiWorld, player: int) -> None: - total_locations = world.total_locations[player].value - num_of_events = total_locations // 25 - if total_locations / 25 == num_of_events: - num_of_events -= 1 - world_region = world.get_region("Petrichor V", player) - - for i in range(num_of_events): - event_loc = RiskOfRainLocation(player, f"Pickup{(i + 1) * 25}", None, world_region) - event_loc.place_locked_item(RiskOfRainItem(f"Pickup{(i + 1) * 25}", ItemClassification.progression, None, player)) - event_loc.access_rule(lambda state, i=i: state.can_reach(f"ItemPickup{((i + 1) * 25) - 1}", player)) - world_region.locations.append(event_loc) - - victory_event = RiskOfRainLocation(player, "Victory", None, world_region) - victory_event.place_locked_item(RiskOfRainItem("Victory", ItemClassification.progression, None, player)) - world_region.locations.append(victory_event) + def create_events(self) -> None: + total_locations = self.options["total_locations"].value + num_of_events = total_locations // 25 + if total_locations / 25 == num_of_events: + num_of_events -= 1 + world_region = self.world.get_region("Petrichor V", self.player) + + for i in range(num_of_events): + event_loc = RiskOfRainLocation(self.player, f"Pickup{(i + 1) * 25}", None, world_region) + event_loc.place_locked_item(RiskOfRainItem(f"Pickup{(i + 1) * 25}", + ItemClassification.progression, None, self.player)) + event_loc.access_rule(lambda state, i=i: state.can_reach(f"ItemPickup{((i + 1) * 25) - 1}", self.player)) + world_region.locations.append(event_loc) + + victory_event = RiskOfRainLocation(self.player, "Victory", None, world_region) + victory_event.place_locked_item(RiskOfRainItem("Victory", ItemClassification.progression, None, self.player)) + world_region.locations.append(victory_event) def create_region(world: MultiWorld, player: int, name: str, locations: List[str] = None) -> Region: From 643205cfe172624323658d402b8a1da99999906c Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 2 Sep 2022 17:42:16 -0500 Subject: [PATCH 003/163] add temp behavior for lttp with notes --- BaseClasses.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 7609875e4b34..4be26d671b33 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -196,18 +196,27 @@ def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optio range(1, self.players + 1)} def set_options(self, args): - for option_key in Options.common_options: - setattr(self, option_key, getattr(args, option_key, {})) - for option_key in Options.per_game_common_options: - setattr(self, option_key, getattr(args, option_key, {})) - for player in self.player_ids: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) + for option_key in Options.common_options: + option_values = getattr(args, option_key, {}) + setattr(self, option_key, option_values) + if player in option_values: + self.worlds[player].options[option_key] = option_values[player] + + for option_key in Options.per_game_common_options: + option_values = getattr(args, option_key, {}) + setattr(self, option_key, option_values) + if player in option_values: + self.worlds[player].options[option_key] = option_values[player] + for option_key in world_type.option_definitions: - setattr(self, option_key, getattr(args, option_key, {})) - self.worlds[player].options[option_key] = getattr(args, option_key)[player] + option_values = getattr(args, option_key, {}) # TODO remove {} after old lttp options + setattr(self, option_key, option_values) # TODO rip out around 0.4.0 + if player in option_values: # TODO more lttp jank + self.worlds[player].options[option_key] = option_values[player] def set_item_links(self): From 695c94cb8797c645795a4947a18f9f1b59991463 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 24 Sep 2022 19:13:30 -0500 Subject: [PATCH 004/163] copy/paste bad --- BaseClasses.py | 17 +++-------------- worlds/AutoWorld.py | 3 ++- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 4be26d671b33..747ee9bd1999 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import itertools from enum import unique, IntEnum, IntFlag import logging import json @@ -200,25 +201,13 @@ def set_options(self, args): self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) - for option_key in Options.common_options: + for option_key in itertools.chain(Options.common_options, Options.per_game_common_options, + world_type.option_definitions): option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) if player in option_values: self.worlds[player].options[option_key] = option_values[player] - for option_key in Options.per_game_common_options: - option_values = getattr(args, option_key, {}) - setattr(self, option_key, option_values) - if player in option_values: - self.worlds[player].options[option_key] = option_values[player] - - for option_key in world_type.option_definitions: - option_values = getattr(args, option_key, {}) # TODO remove {} after old lttp options - setattr(self, option_key, option_values) # TODO rip out around 0.4.0 - if player in option_values: # TODO more lttp jank - self.worlds[player].options[option_key] = option_values[player] - - def set_item_links(self): item_links = {} diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 24d08c70932e..446253296b0a 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -121,7 +121,7 @@ class World(metaclass=AutoWorldRegister): A Game should have its own subclass of World in which it defines the required data structures.""" option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping - options: Dict[str, Option[Any]] = {} # option names to resulting option object + options: Dict[str, Option[Any]] # option names to resulting option object game: str # name the game topology_present: bool = False # indicate if world type has any meaningful layout/pathing @@ -189,6 +189,7 @@ class World(metaclass=AutoWorldRegister): def __init__(self, world: "MultiWorld", player: int): self.world = world self.player = player + self.options = {} # overridable methods that get called by Main.py, sorted by execution order # can also be implemented as a classmethod and called "stage_", From d481eed2587248e066fef77ccdfce4fcf85fcac7 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 29 Sep 2022 11:17:14 -0500 Subject: [PATCH 005/163] convert `set_default_common_options` to a namespace property --- BaseClasses.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 1d29b228d64a..2ce2ad289298 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -2,6 +2,7 @@ import copy import itertools +from argparse import Namespace from enum import unique, IntEnum, IntFlag import logging import json @@ -261,12 +262,13 @@ def set_item_links(self): group["non_local_items"] = item_link["non_local_items"] # intended for unittests - def set_default_common_options(self): - for option_key, option in Options.common_options.items(): - setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids}) - for option_key, option in Options.per_game_common_options.items(): - setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids}) + @property + def default_common_options(self) -> Namespace: self.state = CollectionState(self) + args = Namespace() + for option_key, option in itertools.chain(Options.common_options.items(), Options.per_game_common_options.items()): + setattr(args, option_key, {player_id: option.from_any(option.default) for player_id in self.player_ids}) + return args def secure(self): self.random = secrets.SystemRandom() From f9a8bb2bb7bbb36519ce312cf24b1156eb2c68ef Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 29 Sep 2022 11:17:50 -0500 Subject: [PATCH 006/163] reorganize test call order --- test/dungeons/TestDungeon.py | 3 +-- test/general/__init__.py | 3 +-- test/inverted/TestInverted.py | 3 +-- test/inverted/TestInvertedBombRules.py | 3 +-- test/inverted_minor_glitches/TestInvertedMinor.py | 3 +-- test/inverted_owg/TestInvertedOWG.py | 3 +-- test/minor_glitches/TestMinor.py | 3 +-- test/owg/TestVanillaOWG.py | 3 +-- test/vanilla/TestVanilla.py | 3 +-- 9 files changed, 9 insertions(+), 18 deletions(-) diff --git a/test/dungeons/TestDungeon.py b/test/dungeons/TestDungeon.py index 0568e799f259..04cfbec27127 100644 --- a/test/dungeons/TestDungeon.py +++ b/test/dungeons/TestDungeon.py @@ -15,11 +15,10 @@ class TestDungeon(unittest.TestCase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.starting_regions = [] # Where to start exploring self.remove_exits = [] # Block dungeon exits self.world.difficulty_requirements[1] = difficulties['normal'] diff --git a/test/general/__init__.py b/test/general/__init__.py index 479f4af520f0..6691b9421f84 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -11,11 +11,10 @@ def setup_default_world(world_type) -> MultiWorld: world.game[1] = world_type.game world.player_name = {1: "Tester"} world.set_seed() - args = Namespace() + args = world.default_common_options for name, option in world_type.option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) world.set_options(args) - world.set_default_common_options() for step in gen_steps: call_all(world, step) return world diff --git a/test/inverted/TestInverted.py b/test/inverted/TestInverted.py index 0c96f0b26dbd..856b97f5905e 100644 --- a/test/inverted/TestInverted.py +++ b/test/inverted/TestInverted.py @@ -15,11 +15,10 @@ class TestInverted(TestBase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.difficulty_requirements[1] = difficulties['normal'] self.world.mode[1] = "inverted" create_inverted_regions(self.world, 1) diff --git a/test/inverted/TestInvertedBombRules.py b/test/inverted/TestInvertedBombRules.py index f6afa9d0dc16..9cccdc93ffcf 100644 --- a/test/inverted/TestInvertedBombRules.py +++ b/test/inverted/TestInvertedBombRules.py @@ -16,11 +16,10 @@ class TestInvertedBombRules(unittest.TestCase): def setUp(self): self.world = MultiWorld(1) self.world.mode[1] = "inverted" - args = Namespace + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.difficulty_requirements[1] = difficulties['normal'] create_inverted_regions(self.world, 1) create_dungeons(self.world, 1) diff --git a/test/inverted_minor_glitches/TestInvertedMinor.py b/test/inverted_minor_glitches/TestInvertedMinor.py index 42e7c942d682..a51ba143cdee 100644 --- a/test/inverted_minor_glitches/TestInvertedMinor.py +++ b/test/inverted_minor_glitches/TestInvertedMinor.py @@ -16,11 +16,10 @@ class TestInvertedMinor(TestBase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.mode[1] = "inverted" self.world.logic[1] = "minorglitches" self.world.difficulty_requirements[1] = difficulties['normal'] diff --git a/test/inverted_owg/TestInvertedOWG.py b/test/inverted_owg/TestInvertedOWG.py index 064dd9e08395..f7ca5f3bdcb5 100644 --- a/test/inverted_owg/TestInvertedOWG.py +++ b/test/inverted_owg/TestInvertedOWG.py @@ -17,11 +17,10 @@ class TestInvertedOWG(TestBase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.logic[1] = "owglitches" self.world.mode[1] = "inverted" self.world.difficulty_requirements[1] = difficulties['normal'] diff --git a/test/minor_glitches/TestMinor.py b/test/minor_glitches/TestMinor.py index 81c09cfb2789..b16c4ce353e0 100644 --- a/test/minor_glitches/TestMinor.py +++ b/test/minor_glitches/TestMinor.py @@ -16,11 +16,10 @@ class TestMinor(TestBase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.logic[1] = "minorglitches" self.world.difficulty_requirements[1] = difficulties['normal'] create_regions(self.world, 1) diff --git a/test/owg/TestVanillaOWG.py b/test/owg/TestVanillaOWG.py index e5489117a71f..1d11a63b2934 100644 --- a/test/owg/TestVanillaOWG.py +++ b/test/owg/TestVanillaOWG.py @@ -17,11 +17,10 @@ class TestVanillaOWG(TestBase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.difficulty_requirements[1] = difficulties['normal'] self.world.logic[1] = "owglitches" create_regions(self.world, 1) diff --git a/test/vanilla/TestVanilla.py b/test/vanilla/TestVanilla.py index e5ee73406aac..368f0f127658 100644 --- a/test/vanilla/TestVanilla.py +++ b/test/vanilla/TestVanilla.py @@ -15,11 +15,10 @@ class TestVanilla(TestBase): def setUp(self): self.world = MultiWorld(1) - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) - self.world.set_default_common_options() self.world.logic[1] = "noglitches" self.world.difficulty_requirements[1] = difficulties['normal'] create_regions(self.world, 1) From cded105e6c881db3b76b39279e4fb1a6829fb123 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 29 Sep 2022 11:18:12 -0500 Subject: [PATCH 007/163] have fill_restrictive use the new options system --- Fill.py | 3 ++- test/general/TestFill.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Fill.py b/Fill.py index c62eaabde8bb..8ec6efaef3b2 100644 --- a/Fill.py +++ b/Fill.py @@ -5,6 +5,7 @@ from collections import Counter, deque from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item +from Options import Accessibility from worlds.AutoWorld import call_all @@ -52,7 +53,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.accessibility[item_to_place.player] == 'minimal': + if world.worlds[item_to_place.player].options["accessibility"] == Accessibility.option_minimal: perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game diff --git a/test/general/TestFill.py b/test/general/TestFill.py index 8ce5b3b2816f..ead95c1e3860 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -1,5 +1,9 @@ +import itertools from typing import List, Iterable import unittest + +import Options +from Options import Accessibility from worlds.AutoWorld import World from Fill import FillError, balance_multiworld_progression, fill_restrictive, distribute_items_restrictive from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \ @@ -10,6 +14,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld: multi_world = MultiWorld(players) multi_world.player_name = {} + args = multi_world.default_common_options for i in range(players): player_id = i+1 world = World(multi_world, player_id) @@ -20,8 +25,12 @@ def generate_multi_world(players: int = 1) -> MultiWorld: "Menu Region Hint", player_id, multi_world) multi_world.regions.append(region) + for option_key in itertools.chain(Options.common_options, Options.per_game_common_options): + option_value = getattr(args, option_key, {}) + setattr(multi_world, option_key, option_value) + multi_world.worlds[player_id].options[option_key] = option_value[player_id] + multi_world.set_seed(0) - multi_world.set_default_common_options() return multi_world @@ -187,7 +196,7 @@ def test_minimal_fill(self): items = player1.prog_items locations = player1.locations - multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal + multi_world.worlds[player1.id].options["accessibility"] = Accessibility.from_any(Accessibility.option_minimal) multi_world.completion_condition[player1.id] = lambda state: state.has( items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( From 52290df9449191a718018cd0c063004e2483cbb5 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 18 Oct 2022 10:34:14 -0500 Subject: [PATCH 008/163] update world api --- docs/world api.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index cf26cfd967a3..f37b015d38a3 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -85,7 +85,7 @@ inside a World object. ### Player Options Players provide customized settings for their World in the form of yamls. -Those are accessible through `self.world.[self.player]`. A dict +Those are accessible through `self.world.options[""]`. A dict of valid options has to be provided in `self.option_definitions`. Options are automatically added to the `World` object for easy access. @@ -210,7 +210,7 @@ AP will only import the `__init__.py`. Depending on code size it makes sense to use multiple files and use relative imports to access them. e.g. `from .Options import mygame_options` from your `__init__.py` will load -`world/[world_name]/Options.py` and make its `mygame_options` accesible. +`world/[world_name]/Options.py` and make its `mygame_options` accessible. When imported names pile up it may be easier to use `from . import Options` and access the variable as `Options.mygame_options`. @@ -262,7 +262,8 @@ to describe it and a `display_name` property for display on the website and in spoiler logs. The actual name as used in the yaml is defined in a `dict[str, Option]`, that is -assigned to the world under `self.option_definitions`. +assigned to the world under `self.option_definitions`. By convention, the string +that defines your option should be in `snake_case`. Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. For more see `Options.py` in AP's base directory. @@ -455,7 +456,7 @@ In addition, the following methods can be implemented and attributes can be set ```python def generate_early(self) -> None: # read player settings to world instance - self.final_boss_hp = self.world.final_boss_hp[self.player].value + self.final_boss_hp = self.world.options["final_boss_hp"].value ``` #### create_item From 5896e6ca92b374c6cf099bbeaf38dd14d4cf856c Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 18 Oct 2022 11:05:52 -0500 Subject: [PATCH 009/163] update soe tests --- test/soe/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/soe/__init__.py b/test/soe/__init__.py index 0161a6c32fbc..6f4d41aff1dc 100644 --- a/test/soe/__init__.py +++ b/test/soe/__init__.py @@ -17,11 +17,10 @@ def setUp(self): self.world.game[1] = self.game self.world.player_name = {1: "Tester"} self.world.set_seed() - args = Namespace() + args = self.world.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items(): setattr(args, name, {1: option.from_any(self.options.get(name, option.default))}) self.world.set_options(args) - self.world.set_default_common_options() for step in gen_steps: call_all(self.world, step) From 2581d574978f6d20f629203e7afe66e9f6a6fdaa Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 29 Oct 2022 13:24:34 -0500 Subject: [PATCH 010/163] fix world api --- docs/world api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index f37b015d38a3..5cc7f9148195 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -85,7 +85,7 @@ inside a World object. ### Player Options Players provide customized settings for their World in the form of yamls. -Those are accessible through `self.world.options[""]`. A dict +Those are accessible through `self.options[""]`. A dict of valid options has to be provided in `self.option_definitions`. Options are automatically added to the `World` object for easy access. @@ -456,7 +456,7 @@ In addition, the following methods can be implemented and attributes can be set ```python def generate_early(self) -> None: # read player settings to world instance - self.final_boss_hp = self.world.options["final_boss_hp"].value + self.final_boss_hp = self.options["final_boss_hp"].value ``` #### create_item @@ -676,9 +676,9 @@ def generate_output(self, output_directory: str): in self.world.precollected_items[self.player]], "final_boss_hp": self.final_boss_hp, # store option name "easy", "normal" or "hard" for difficuly - "difficulty": self.world.difficulty[self.player].current_key, + "difficulty": self.options["difficulty"].current_key, # store option value True or False for fixing a glitch - "fix_xyz_glitch": self.world.fix_xyz_glitch[self.player].value + "fix_xyz_glitch": self.options["fix_xyz_glitch"].value } # point to a ROM specified by the installation src = Utils.get_options()["mygame_options"]["rom_file"] From 121152be5bc0f41ad690b575ac11ef49c97e6a6e Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Mon, 9 Jan 2023 02:20:08 +0100 Subject: [PATCH 011/163] core: auto initialize a dataclass on the World class with the option results --- BaseClasses.py | 10 +++++--- Fill.py | 2 +- Options.py | 42 +++++++++++++++++++------------ docs/world api.md | 53 ++++++++++++++++++++------------------- test/general/TestFill.py | 8 +++--- worlds/AutoWorld.py | 9 ++++--- worlds/ror2/Options.py | 54 ++++++++++++++++++++-------------------- worlds/ror2/__init__.py | 54 +++++++++++++++++++++------------------- 8 files changed, 126 insertions(+), 106 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index aed336a24e09..a59e1b415272 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -3,13 +3,13 @@ import copy import itertools -from argparse import Namespace from enum import unique, IntEnum, IntFlag import logging import json import functools from collections import OrderedDict, Counter, deque -from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple +from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple, Type, \ + get_type_hints import typing # this can go away when Python 3.8 support is dropped import secrets import random @@ -214,8 +214,10 @@ def set_options(self, args: Namespace) -> None: world_type.option_definitions): option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) - if player in option_values: - self.worlds[player].options[option_key] = option_values[player] + # TODO - remove this loop once all worlds use options dataclasses + options_dataclass: Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass + self.worlds[player].o = options_dataclass(**{option_key: getattr(args, option_key)[player] + for option_key in get_type_hints(options_dataclass)}) def set_item_links(self): item_links = {} diff --git a/Fill.py b/Fill.py index cb8d2a878741..2d6647dea603 100644 --- a/Fill.py +++ b/Fill.py @@ -55,7 +55,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.worlds[item_to_place.player].options["accessibility"] == Accessibility.option_minimal: + if world.worlds[item_to_place.player].o.accessibility == Accessibility.option_minimal: perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game diff --git a/Options.py b/Options.py index ad87f5ebf8d9..3876e59c324c 100644 --- a/Options.py +++ b/Options.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc from copy import deepcopy +from dataclasses import dataclass import math import numbers import typing @@ -862,10 +863,13 @@ class ProgressionBalancing(SpecialRange): } -common_options = { - "progression_balancing": ProgressionBalancing, - "accessibility": Accessibility -} +@dataclass +class CommonOptions: + progression_balancing: ProgressionBalancing + accessibility: Accessibility + +common_options = typing.get_type_hints(CommonOptions) +# TODO - remove this dict once all worlds use options dataclasses class ItemSet(OptionSet): @@ -982,18 +986,24 @@ def verify(self, world, player_name: str, plando_options) -> None: raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.") -per_game_common_options = { - **common_options, # can be overwritten per-game - "local_items": LocalItems, - "non_local_items": NonLocalItems, - "early_items": EarlyItems, - "start_inventory": StartInventory, - "start_hints": StartHints, - "start_location_hints": StartLocationHints, - "exclude_locations": ExcludeLocations, - "priority_locations": PriorityLocations, - "item_links": ItemLinks -} +@dataclass +class PerGameCommonOptions(CommonOptions): + local_items: LocalItems + non_local_items: NonLocalItems + early_items: EarlyItems + start_inventory: StartInventory + start_hints: StartHints + start_location_hints: StartLocationHints + exclude_locations: ExcludeLocations + priority_locations: PriorityLocations + item_links: ItemLinks + +per_game_common_options = typing.get_type_hints(PerGameCommonOptions) +# TODO - remove this dict once all worlds use options dataclasses + + +GameOptions = typing.TypeVar("GameOptions", bound=PerGameCommonOptions) + if __name__ == "__main__": diff --git a/docs/world api.md b/docs/world api.md index 5cc7f9148195..6733ca093e0d 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -85,9 +85,10 @@ inside a World object. ### Player Options Players provide customized settings for their World in the form of yamls. -Those are accessible through `self.options[""]`. A dict -of valid options has to be provided in `self.option_definitions`. Options are automatically -added to the `World` object for easy access. +A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`. +(It must be a subclass of `PerGameCommonOptions`.) +Option results are automatically added to the `World` object for easy access. +Those are accessible through `self.o.`. ### World Options @@ -209,11 +210,11 @@ See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requireme AP will only import the `__init__.py`. Depending on code size it makes sense to use multiple files and use relative imports to access them. -e.g. `from .Options import mygame_options` from your `__init__.py` will load -`world/[world_name]/Options.py` and make its `mygame_options` accessible. +e.g. `from .Options import MyGameOptions` from your `__init__.py` will load +`world/[world_name]/Options.py` and make its `MyGameOptions` accessible. When imported names pile up it may be easier to use `from . import Options` -and access the variable as `Options.mygame_options`. +and access the variable as `Options.MyGameOptions`. Imports from directories outside your world should use absolute imports. Correct use of relative / absolute imports is required for zipped worlds to @@ -261,9 +262,9 @@ Each option has its own class, inherits from a base option type, has a docstring to describe it and a `display_name` property for display on the website and in spoiler logs. -The actual name as used in the yaml is defined in a `dict[str, Option]`, that is -assigned to the world under `self.option_definitions`. By convention, the string -that defines your option should be in `snake_case`. +The actual name as used in the yaml is defined via the field names of a `dataclass` that is +assigned to the world under `self.options_dataclass`. By convention, the strings +that define your option names should be in `snake_case`. Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. For more see `Options.py` in AP's base directory. @@ -298,8 +299,8 @@ default = 0 ```python # Options.py -from Options import Toggle, Range, Choice, Option -import typing +from dataclasses import dataclass +from Options import Toggle, Range, Choice, PerGameCommonOptions class Difficulty(Choice): """Sets overall game difficulty.""" @@ -322,25 +323,26 @@ class FixXYZGlitch(Toggle): """Fixes ABC when you do XYZ""" display_name = "Fix XYZ Glitch" -# By convention we call the options dict variable `_options`. -mygame_options: typing.Dict[str, type(Option)] = { - "difficulty": Difficulty, - "final_boss_hp": FinalBossHP, - "fix_xyz_glitch": FixXYZGlitch -} +# By convention, we call the options dataclass `Options`. +@dataclass +class MyGameOptions(PerGameCommonOptions): + difficulty: Difficulty + final_boss_hp: FinalBossHP + fix_xyz_glitch: FixXYZGlitch ``` ```python # __init__.py from worlds.AutoWorld import World -from .Options import mygame_options # import the options dict +from .Options import MyGameOptions # import the options dataclass class MyGameWorld(World): #... - option_definitions = mygame_options # assign the options dict to the world + options_dataclass = MyGameOptions # assign the options dataclass to the world + o: MyGameOptions # typing for option results #... ``` - + ### Local or Remote A world with `remote_items` set to `True` gets all items items from the server @@ -358,7 +360,7 @@ more natural. These games typically have been edited to 'bake in' the items. ```python # world/mygame/__init__.py -from .Options import mygame_options # the options we defined earlier +from .Options import MyGameOptions # the options we defined earlier from .Items import mygame_items # data used below to add items to the World from .Locations import mygame_locations # same as above from worlds.AutoWorld import World @@ -374,7 +376,8 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation class MyGameWorld(World): """Insert description of the world/game here.""" game: str = "My Game" # name of the game/world - option_definitions = mygame_options # options the player can set + options_dataclass = MyGameOptions # options the player can set + o: MyGameOptions # typing for option results topology_present: bool = True # show path to required location checks in spoiler remote_items: bool = False # True if all items come from the server remote_start_inventory: bool = False # True if start inventory comes from the server @@ -456,7 +459,7 @@ In addition, the following methods can be implemented and attributes can be set ```python def generate_early(self) -> None: # read player settings to world instance - self.final_boss_hp = self.options["final_boss_hp"].value + self.final_boss_hp = self.o.final_boss_hp.value ``` #### create_item @@ -676,9 +679,9 @@ def generate_output(self, output_directory: str): in self.world.precollected_items[self.player]], "final_boss_hp": self.final_boss_hp, # store option name "easy", "normal" or "hard" for difficuly - "difficulty": self.options["difficulty"].current_key, + "difficulty": self.o.difficulty.current_key, # store option value True or False for fixing a glitch - "fix_xyz_glitch": self.options["fix_xyz_glitch"].value + "fix_xyz_glitch": self.o.fix_xyz_glitch.value } # point to a ROM specified by the installation src = Utils.get_options()["mygame_options"]["rom_file"] diff --git a/test/general/TestFill.py b/test/general/TestFill.py index 2ccc5430df72..ad8261ee8f2c 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -1,5 +1,5 @@ import itertools -from typing import List, Iterable +from typing import get_type_hints, List, Iterable import unittest import Options @@ -28,7 +28,9 @@ def generate_multi_world(players: int = 1) -> MultiWorld: for option_key in itertools.chain(Options.common_options, Options.per_game_common_options): option_value = getattr(args, option_key, {}) setattr(multi_world, option_key, option_value) - multi_world.worlds[player_id].options[option_key] = option_value[player_id] + # TODO - remove this loop once all worlds use options dataclasses + world.o = world.options_dataclass(**{option_key: getattr(args, option_key)[player_id] + for option_key in get_type_hints(world.options_dataclass)}) multi_world.set_seed(0) @@ -196,7 +198,7 @@ def test_minimal_fill(self): items = player1.prog_items locations = player1.locations - multi_world.worlds[player1.id].options["accessibility"] = Accessibility.from_any(Accessibility.option_minimal) + multi_world.worlds[player1.id].o.accessibility = Accessibility.from_any(Accessibility.option_minimal) multi_world.completion_condition[player1.id] = lambda state: state.has( items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 943c25b59f3f..6f0c2029abd7 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -5,7 +5,7 @@ import pathlib from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING -from Options import AssembleOptions, Option +from Options import AssembleOptions, GameOptions, PerGameCommonOptions from BaseClasses import CollectionState if TYPE_CHECKING: @@ -130,8 +130,10 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - option_definitions: Dict[str, AssembleOptions] = {} # link your Options mapping - options: Dict[str, Option[Any]] # automatically populated option names to resulting option object + option_definitions: Dict[str, AssembleOptions] = {} # TODO - remove this once all worlds use options dataclasses + options_dataclass: Type[GameOptions] = PerGameCommonOptions # link your Options mapping + o: PerGameCommonOptions + game: str # name the game topology_present: bool = False # indicate if world type has any meaningful layout/pathing @@ -199,7 +201,6 @@ class World(metaclass=AutoWorldRegister): def __init__(self, world: "MultiWorld", player: int): self.world = world self.player = player - self.options = {} # overridable methods that get called by Main.py, sorted by execution order # can also be implemented as a classmethod and called "stage_", diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py index a95cbf597ae4..fd61b951d3d7 100644 --- a/worlds/ror2/Options.py +++ b/worlds/ror2/Options.py @@ -1,5 +1,5 @@ -from typing import Dict -from Options import Option, DefaultOnToggle, Range, Choice +from dataclasses import dataclass +from Options import DefaultOnToggle, Range, Choice, PerGameCommonOptions class TotalLocations(Range): @@ -150,28 +150,28 @@ class ItemWeights(Choice): option_scraps_only = 8 -# define a dictionary for the weights of the generated item pool. -ror2_weights: Dict[str, type(Option)] = { - "green_scrap": GreenScrap, - "red_scrap": RedScrap, - "yellow_scrap": YellowScrap, - "white_scrap": WhiteScrap, - "common_item": CommonItem, - "uncommon_item": UncommonItem, - "legendary_item": LegendaryItem, - "boss_item": BossItem, - "lunar_item": LunarItem, - "equipment": Equipment -} - -ror2_options: Dict[str, type(Option)] = { - "total_locations": TotalLocations, - "total_revivals": TotalRevivals, - "start_with_revive": StartWithRevive, - "final_stage_death": FinalStageDeath, - "item_pickup_step": ItemPickupStep, - "enable_lunar": AllowLunarItems, - "item_weights": ItemWeights, - "item_pool_presets": ItemPoolPresetToggle, - **ror2_weights -} +# define a class for the weights of the generated item pool. +@dataclass +class ROR2Weights: + green_scrap: GreenScrap + red_scrap: RedScrap + yellow_scrap: YellowScrap + white_scrap: WhiteScrap + common_item: CommonItem + uncommon_item: UncommonItem + legendary_item: LegendaryItem + boss_item: BossItem + lunar_item: LunarItem + equipment: Equipment + + +@dataclass +class ROR2Options(PerGameCommonOptions, ROR2Weights): + total_locations: TotalLocations + total_revivals: TotalRevivals + start_with_revive: StartWithRevive + final_stage_death: FinalStageDeath + item_pickup_step: ItemPickupStep + enable_lunar: AllowLunarItems + item_weights: ItemWeights + item_pool_presets: ItemPoolPresetToggle diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index bcc9f174dc0e..c420f3e7bd26 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -1,11 +1,11 @@ import string -from typing import Dict, List +from typing import Dict, get_type_hints, List from .Items import RiskOfRainItem, item_table, item_pool_weights from .Locations import RiskOfRainLocation, item_pickups from .Rules import set_rules from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial -from .Options import ror2_options, ItemWeights +from .Options import ItemWeights, ROR2Options from worlds.AutoWorld import World, WebWorld client_version = 1 @@ -29,7 +29,9 @@ class RiskOfRainWorld(World): first crash landing. """ game: str = "Risk of Rain 2" - option_definitions = ror2_options + option_definitions = get_type_hints(ROR2Options) + options_dataclass = ROR2Options + o: ROR2Options topology_present = False item_name_to_id = item_table @@ -42,17 +44,17 @@ class RiskOfRainWorld(World): def generate_early(self) -> None: # figure out how many revivals should exist in the pool - self.total_revivals = int(self.options["total_revivals"].value // 100 * self.options["total_locations"].value) + self.total_revivals = int(self.o.total_revivals.value // 100 * self.o.total_locations.value) def generate_basic(self) -> None: # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend - if self.options["start_with_revive"].value: + if self.o.start_with_revive.value: self.world.push_precollected(self.world.create_item("Dio's Best Friend", self.player)) # if presets are enabled generate junk_pool from the selected preset - pool_option = self.options["item_weights"].value + pool_option = self.o.item_weights.value junk_pool: Dict[str, int] = {} - if self.options["item_pool_presets"]: + if self.o.item_pool_presets: # generate chaos weights if the preset is chosen if pool_option == ItemWeights.option_chaos: for name, max_value in item_pool_weights[pool_option].items(): @@ -61,20 +63,20 @@ def generate_basic(self) -> None: junk_pool = item_pool_weights[pool_option].copy() else: # generate junk pool from user created presets junk_pool = { - "Item Scrap, Green": self.options["green_scrap"].value, - "Item Scrap, Red": self.options["red_scrap"].value, - "Item Scrap, Yellow": self.options["yellow_scrap"].value, - "Item Scrap, White": self.options["white_scrap"].value, - "Common Item": self.options["common_item"].value, - "Uncommon Item": self.options["uncommon_item"].value, - "Legendary Item": self.options["legendary_item"].value, - "Boss Item": self.options["boss_item"].value, - "Lunar Item": self.options["lunar_item"].value, - "Equipment": self.options["equipment"].value + "Item Scrap, Green": self.o.green_scrap.value, + "Item Scrap, Red": self.o.red_scrap.value, + "Item Scrap, Yellow": self.o.yellow_scrap.value, + "Item Scrap, White": self.o.white_scrap.value, + "Common Item": self.o.common_item.value, + "Uncommon Item": self.o.uncommon_item.value, + "Legendary Item": self.o.legendary_item.value, + "Boss Item": self.o.boss_item.value, + "Lunar Item": self.o.lunar_item.value, + "Equipment": self.o.equipment.value } # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled - if not (self.options["enable_lunar"] or pool_option == ItemWeights.option_lunartic): + if not (self.o.enable_lunar or pool_option == ItemWeights.option_lunartic): junk_pool.pop("Lunar Item") # Generate item pool @@ -84,7 +86,7 @@ def generate_basic(self) -> None: # Fill remaining items with randomly generated junk itempool += self.world.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), - k=self.options["total_locations"].value - self.total_revivals) + k=self.o.total_locations.value - self.total_revivals) # Convert itempool into real items itempool = list(map(lambda name: self.create_item(name), itempool)) @@ -97,7 +99,7 @@ def set_rules(self) -> None: def create_regions(self) -> None: menu = create_region(self.world, self.player, "Menu") petrichor = create_region(self.world, self.player, "Petrichor V", - [f"ItemPickup{i + 1}" for i in range(self.options["total_locations"].value)]) + [f"ItemPickup{i + 1}" for i in range(self.o.total_locations.value)]) connection = Entrance(self.player, "Lobby", menu) menu.exits.append(connection) @@ -109,12 +111,12 @@ def create_regions(self) -> None: def fill_slot_data(self): return { - "itemPickupStep": self.options["item_pickup_step"].value, + "itemPickupStep": self.o.item_pickup_step.value, "seed": "".join(self.world.slot_seeds[self.player].choice(string.digits) for _ in range(16)), - "totalLocations": self.options["total_locations"].value, - "totalRevivals": self.options["total_revivals"].value, - "startWithDio": self.options["start_with_revive"].value, - "FinalStageDeath": self.options["final_stage_death"].value + "totalLocations": self.o.total_locations.value, + "totalRevivals": self.o.total_revivals.value, + "startWithDio": self.o.start_with_revive.value, + "FinalStageDeath": self.o.final_stage_death.value } def create_item(self, name: str) -> Item: @@ -129,7 +131,7 @@ def create_item(self, name: str) -> Item: return item def create_events(self) -> None: - total_locations = self.options["total_locations"].value + total_locations = self.o.total_locations.value num_of_events = total_locations // 25 if total_locations / 25 == num_of_events: num_of_events -= 1 From 82ff1259e21f74e2e8b30a9c24f6cf0edf9c7890 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Mon, 9 Jan 2023 02:55:02 +0100 Subject: [PATCH 012/163] core: auto initialize a dataclass on the World class with the option results: small tying improvement --- BaseClasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index a59e1b415272..33dbe8d1a36b 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -215,7 +215,7 @@ def set_options(self, args: Namespace) -> None: option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) # TODO - remove this loop once all worlds use options dataclasses - options_dataclass: Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass + options_dataclass: Type[Options.GameOptions] = self.worlds[player].options_dataclass self.worlds[player].o = options_dataclass(**{option_key: getattr(args, option_key)[player] for option_key in get_type_hints(options_dataclass)}) From 43353de5c3777f23aaf94d8754e35b832c6d810c Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 9 Jan 2023 13:10:32 -0600 Subject: [PATCH 013/163] add `as_dict` method to the options dataclass --- Options.py | 10 ++++++++++ docs/world api.md | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Options.py b/Options.py index 3876e59c324c..4d8c2c53b1ed 100644 --- a/Options.py +++ b/Options.py @@ -868,6 +868,16 @@ class CommonOptions: progression_balancing: ProgressionBalancing accessibility: Accessibility + def as_dict(self, *args: str) -> typing.Dict: + option_results = {} + for option_name in args: + if option_name in self.__annotations__: + option_results[option_name] = getattr(self, option_name).value + else: + raise ValueError(f"{option_name} not found in {self.__annotations__}") + return option_results + + common_options = typing.get_type_hints(CommonOptions) # TODO - remove this dict once all worlds use options dataclasses diff --git a/docs/world api.md b/docs/world api.md index 6733ca093e0d..eba22ed0be18 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -88,7 +88,8 @@ Players provide customized settings for their World in the form of yamls. A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`. (It must be a subclass of `PerGameCommonOptions`.) Option results are automatically added to the `World` object for easy access. -Those are accessible through `self.o.`. +Those are accessible through `self.o.`, and you can get a dictionary of the option values via +`self.o.as_dict()`, passing the desired options as strings. ### World Options From 76833d5809a3eb43193e692636dac7119d2a2fe3 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 9 Jan 2023 13:54:18 -0600 Subject: [PATCH 014/163] fix namespace issues with tests --- test/TestBase.py | 3 +-- worlds/alttp/test/dungeons/TestDungeon.py | 2 +- worlds/alttp/test/inverted/TestInverted.py | 3 +-- worlds/alttp/test/inverted/TestInvertedBombRules.py | 2 +- .../alttp/test/inverted_minor_glitches/TestInvertedMinor.py | 3 +-- worlds/alttp/test/inverted_owg/TestInvertedOWG.py | 3 +-- worlds/alttp/test/minor_glitches/TestMinor.py | 3 +-- worlds/alttp/test/owg/TestVanillaOWG.py | 3 +-- worlds/alttp/test/vanilla/TestVanilla.py | 3 +-- worlds/ror2/__init__.py | 6 +++--- 10 files changed, 12 insertions(+), 19 deletions(-) diff --git a/test/TestBase.py b/test/TestBase.py index 8a17232bd1fe..6d1a85b200f4 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -118,13 +118,12 @@ def world_setup(self, seed: typing.Optional[int] = None) -> None: self.multiworld.game[1] = self.game self.multiworld.player_name = {1: "Tester"} self.multiworld.set_seed(seed) - args = Namespace() + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items(): setattr(args, name, { 1: option.from_any(self.options.get(name, getattr(option, "default"))) }) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() for step in gen_steps: call_all(self.multiworld, step) diff --git a/worlds/alttp/test/dungeons/TestDungeon.py b/worlds/alttp/test/dungeons/TestDungeon.py index 4018d0fc0a4e..9641f2ce8ec4 100644 --- a/worlds/alttp/test/dungeons/TestDungeon.py +++ b/worlds/alttp/test/dungeons/TestDungeon.py @@ -15,7 +15,7 @@ class TestDungeon(unittest.TestCase): def setUp(self): self.multiworld = MultiWorld(1) - args = self.world.default_common_options + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) diff --git a/worlds/alttp/test/inverted/TestInverted.py b/worlds/alttp/test/inverted/TestInverted.py index 309a34d54b95..95de61da648e 100644 --- a/worlds/alttp/test/inverted/TestInverted.py +++ b/worlds/alttp/test/inverted/TestInverted.py @@ -15,11 +15,10 @@ class TestInverted(TestBase): def setUp(self): self.multiworld = MultiWorld(1) - args = Namespace() + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.mode[1] = "inverted" create_inverted_regions(self.multiworld, 1) diff --git a/worlds/alttp/test/inverted/TestInvertedBombRules.py b/worlds/alttp/test/inverted/TestInvertedBombRules.py index 9d386b33c39a..1bb8a79be767 100644 --- a/worlds/alttp/test/inverted/TestInvertedBombRules.py +++ b/worlds/alttp/test/inverted/TestInvertedBombRules.py @@ -16,7 +16,7 @@ class TestInvertedBombRules(unittest.TestCase): def setUp(self): self.multiworld = MultiWorld(1) self.multiworld.mode[1] = "inverted" - args = self.world.default_common_options + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) diff --git a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py index 7ea7980bbe60..f9068da60a8e 100644 --- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py +++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py @@ -16,11 +16,10 @@ class TestInvertedMinor(TestBase): def setUp(self): self.multiworld = MultiWorld(1) - args = Namespace() + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.mode[1] = "inverted" self.multiworld.logic[1] = "minorglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py index 7dae3589296e..78f008680a55 100644 --- a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py +++ b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py @@ -17,11 +17,10 @@ class TestInvertedOWG(TestBase): def setUp(self): self.multiworld = MultiWorld(1) - args = Namespace() + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.logic[1] = "owglitches" self.multiworld.mode[1] = "inverted" self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/minor_glitches/TestMinor.py b/worlds/alttp/test/minor_glitches/TestMinor.py index ec92b5563ef1..a0a87e913eeb 100644 --- a/worlds/alttp/test/minor_glitches/TestMinor.py +++ b/worlds/alttp/test/minor_glitches/TestMinor.py @@ -16,11 +16,10 @@ class TestMinor(TestBase): def setUp(self): self.multiworld = MultiWorld(1) - args = self.world.default_common_options + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.logic[1] = "minorglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/alttp/test/owg/TestVanillaOWG.py b/worlds/alttp/test/owg/TestVanillaOWG.py index a12679b8cfd1..fa288ea5b44e 100644 --- a/worlds/alttp/test/owg/TestVanillaOWG.py +++ b/worlds/alttp/test/owg/TestVanillaOWG.py @@ -17,11 +17,10 @@ class TestVanillaOWG(TestBase): def setUp(self): self.multiworld = MultiWorld(1) - args = Namespace() + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.logic[1] = "owglitches" self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/alttp/test/vanilla/TestVanilla.py b/worlds/alttp/test/vanilla/TestVanilla.py index 32c9c6180049..c2352ebb20e5 100644 --- a/worlds/alttp/test/vanilla/TestVanilla.py +++ b/worlds/alttp/test/vanilla/TestVanilla.py @@ -15,11 +15,10 @@ class TestVanilla(TestBase): def setUp(self): self.multiworld = MultiWorld(1) - args = Namespace() + args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.multiworld.set_options(args) - self.multiworld.set_default_common_options() self.multiworld.logic[1] = "noglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 12afd79e5327..b50b4e675ddf 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -2,7 +2,7 @@ from typing import Dict, get_type_hints, List from .Items import RiskOfRainItem, item_table, item_pool_weights from .Locations import RiskOfRainLocation, item_pickups -from .Options import ItemWeights, ror2_options +from .Options import ItemWeights, ROR2Options from .Rules import set_rules from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial @@ -49,7 +49,7 @@ def generate_early(self) -> None: def generate_basic(self) -> None: # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend if self.o.start_with_revive.value: - self.world.push_precollected(self.world.create_item("Dio's Best Friend", self.player)) + self.multiworld.push_precollected(self.create_item("Dio's Best Friend")) # if presets are enabled generate junk_pool from the selected preset pool_option = self.o.item_weights.value @@ -135,7 +135,7 @@ def create_events(self) -> None: num_of_events = total_locations // 25 if total_locations / 25 == num_of_events: num_of_events -= 1 - world_region = self.world.get_region("Petrichor V", self.player) + world_region = self.multiworld.get_region("Petrichor V", self.player) for i in range(num_of_events): event_loc = RiskOfRainLocation(self.player, f"Pickup{(i + 1) * 25}", None, world_region) From d85fb695add1a0d30c06e5b5cf039f0e1c270c79 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 12 Feb 2023 12:23:16 -0600 Subject: [PATCH 015/163] have current option updates use `.value` instead of changing the option --- worlds/factorio/__init__.py | 4 ++-- worlds/oot/__init__.py | 4 ++-- worlds/sm/__init__.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index 0053a016e372..f58be1b1758e 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -73,8 +73,8 @@ def __init__(self, world, player: int): generate_output = generate_mod def generate_early(self) -> None: - self.multiworld.max_tech_cost[self.player] = max(self.multiworld.max_tech_cost[self.player], - self.multiworld.min_tech_cost[self.player]) + self.multiworld.max_tech_cost[self.player].value = max(self.multiworld.max_tech_cost[self.player].value, + self.multiworld.min_tech_cost[self.player].value) self.tech_mix = self.multiworld.tech_cost_mix[self.player] self.skip_silo = self.multiworld.silo[self.player].value == Silo.option_spawn diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 815182655d33..f594e4c6eb2a 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -30,7 +30,7 @@ from Utils import get_options from BaseClasses import MultiWorld, CollectionState, RegionType, Tutorial, LocationProgressType -from Options import Range, Toggle, VerifyKeys +from Options import Range, Toggle, VerifyKeys, Accessibility from Fill import fill_restrictive, fast_fill, FillError from worlds.generic.Rules import exclusion_rules, add_item_rule from ..AutoWorld import World, AutoLogicRegister, WebWorld @@ -240,7 +240,7 @@ def generate_early(self): # No Logic forces all tricks on, prog balancing off and beatable-only elif self.logic_rules == 'no_logic': self.multiworld.progression_balancing[self.player].value = False - self.multiworld.accessibility[self.player] = self.multiworld.accessibility[self.player].from_text("minimal") + self.multiworld.accessibility[self.player].value = Accessibility.option_minimal for trick in normalized_name_tricks.values(): setattr(self, trick['name'], True) diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index c7f41092f596..63ff651e8982 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -129,7 +129,7 @@ def generate_early(self): self.remote_items = self.multiworld.remote_items[self.player] if (len(self.variaRando.randoExec.setup.restrictedLocs) > 0): - self.multiworld.accessibility[self.player] = self.multiworld.accessibility[self.player].from_text("minimal") + self.multiworld.accessibility[self.player].value = Accessibility.option_minimal logger.warning(f"accessibility forced to 'minimal' for player {self.multiworld.get_player_name(self.player)} because of 'fun' settings") def generate_basic(self): From cfdc0b1fa34647056d3cc81659d4e1b50303afe7 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 12 Feb 2023 12:55:34 -0600 Subject: [PATCH 016/163] update ror2 to use the new options system again --- BaseClasses.py | 4 +- Options.py | 6 ++- worlds/ror2/Options.py | 3 +- worlds/ror2/__init__.py | 103 ++++++++++++++++++---------------------- 4 files changed, 54 insertions(+), 62 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index f062ed6018bf..f3e97d570b97 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -238,9 +238,9 @@ def set_options(self, args: Namespace) -> None: option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) # TODO - remove this loop once all worlds use options dataclasses - options_dataclass: Type[Options.GameOptions] = self.worlds[player].options_dataclass + options_dataclass: typing.Type[Options.GameOptions] = self.worlds[player].options_dataclass self.worlds[player].o = options_dataclass(**{option_key: getattr(args, option_key)[player] - for option_key in get_type_hints(options_dataclass)}) + for option_key in typing.get_type_hints(options_dataclass)}) def set_item_links(self): item_links = {} diff --git a/Options.py b/Options.py index 2c69e7b0656d..31956b766733 100644 --- a/Options.py +++ b/Options.py @@ -866,7 +866,11 @@ class CommonOptions: progression_balancing: ProgressionBalancing accessibility: Accessibility - def as_dict(self, *args: str) -> typing.Dict: + def as_dict(self, *args: str) -> typing.Dict[str, typing.Any]: + """ + Pass the option_names you would like returned as a dictionary as strings. + Returns a dictionary of [str, Option.value] + """ option_results = {} for option_name in args: if option_name in self.__annotations__: diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py index f96fa61cf17d..84a3c92ac347 100644 --- a/worlds/ror2/Options.py +++ b/worlds/ror2/Options.py @@ -1,6 +1,5 @@ -from typing import Dict from dataclasses import dataclass -from Options import Option, Toggle, DefaultOnToggle, DeathLink, Range, Choice +from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions # NOTE be aware that since the range of item ids that RoR2 uses is based off of the maximums of checks diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 7f8a7ce6a9fd..ed7a2778b0cf 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -1,4 +1,5 @@ import string +from typing import get_type_hints from .Items import RiskOfRainItem, item_table, item_pool_weights, environment_offest from .Locations import RiskOfRainLocation, get_classic_item_pickups, item_pickups, orderedstage_location @@ -6,7 +7,7 @@ from .RoR2Environments import * from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial -from .Options import ror2_options, ItemWeights +from .Options import ItemWeights, ROR2Options from worlds.AutoWorld import World, WebWorld from .Regions import create_regions @@ -28,7 +29,7 @@ class RiskOfRainWorld(World): Combine loot in surprising ways and master each character until you become the havoc you feared upon your first crash landing. """ - game: str = "Risk of Rain 2" + game = "Risk of Rain 2" option_definitions = get_type_hints(ROR2Options) options_dataclass = ROR2Options o: ROR2Options @@ -44,45 +45,44 @@ class RiskOfRainWorld(World): def generate_early(self) -> None: # figure out how many revivals should exist in the pool - if self.multiworld.goal[self.player] == "classic": - total_locations = self.multiworld.total_locations[self.player].value + if self.o.goal == "classic": + total_locations = self.o.total_locations.value else: total_locations = len( orderedstage_location.get_locations( - chests=self.multiworld.chests_per_stage[self.player].value, - shrines=self.multiworld.shrines_per_stage[self.player].value, - scavengers=self.multiworld.scavengers_per_stage[self.player].value, - scanners=self.multiworld.scanner_per_stage[self.player].value, - altars=self.multiworld.altars_per_stage[self.player].value, - dlc_sotv=self.multiworld.dlc_sotv[self.player].value + chests=self.o.chests_per_stage.value, + shrines=self.o.shrines_per_stage.value, + scavengers=self.o.scavengers_per_stage.value, + scanners=self.o.scanner_per_stage.value, + altars=self.o.altars_per_stage.value, + dlc_sotv=self.o.dlc_sotv.value ) ) - self.total_revivals = int(self.multiworld.total_revivals[self.player].value / 100 * + self.total_revivals = int(self.o.total_revivals.value / 100 * total_locations) - # self.total_revivals = self.multiworld.total_revivals[self.player].value - if self.multiworld.start_with_revive[self.player].value: + if self.o.start_with_revive: self.total_revivals -= 1 def create_items(self) -> None: # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend - if self.multiworld.start_with_revive[self.player]: + if self.o.start_with_revive: self.multiworld.push_precollected(self.multiworld.create_item("Dio's Best Friend", self.player)) environments_pool = {} # only mess with the environments if they are set as items - if self.multiworld.goal[self.player] == "explore": + if self.o.goal == "explore": # figure out all available ordered stages for each tier environment_available_orderedstages_table = environment_vanilla_orderedstages_table - if self.multiworld.dlc_sotv[self.player]: + if self.o.dlc_sotv: environment_available_orderedstages_table = collapse_dict_list_vertical(environment_available_orderedstages_table, environment_sotv_orderedstages_table) environments_pool = shift_by_offset(environment_vanilla_table, environment_offest) - if self.multiworld.dlc_sotv[self.player]: + if self.o.dlc_sotv: environment_offset_table = shift_by_offset(environment_sotv_table, environment_offest) environments_pool = {**environments_pool, **environment_offset_table} - environments_to_precollect = 5 if self.multiworld.begin_with_loop[self.player].value else 1 + environments_to_precollect = 5 if self.o.begin_with_loop.value else 1 # percollect environments for each stage (or just stage 1) for i in range(environments_to_precollect): unlock = self.multiworld.random.choices(list(environment_available_orderedstages_table[i].keys()), k=1) @@ -100,25 +100,15 @@ def create_items(self) -> None: else: junk_pool = item_pool_weights[pool_option].copy() else: # generate junk pool from user created presets - junk_pool = { - "Item Scrap, Green": self.multiworld.green_scrap[self.player].value, - "Item Scrap, Red": self.multiworld.red_scrap[self.player].value, - "Item Scrap, Yellow": self.multiworld.yellow_scrap[self.player].value, - "Item Scrap, White": self.multiworld.white_scrap[self.player].value, - "Common Item": self.multiworld.common_item[self.player].value, - "Uncommon Item": self.multiworld.uncommon_item[self.player].value, - "Legendary Item": self.multiworld.legendary_item[self.player].value, - "Boss Item": self.multiworld.boss_item[self.player].value, - "Lunar Item": self.multiworld.lunar_item[self.player].value, - "Void Item": self.multiworld.void_item[self.player].value, - "Equipment": self.multiworld.equipment[self.player].value - } + junk_pool = self.o.as_dict("Item Scrap, Green", "Item Scrap, Red", "Item Scrap, Yellow", "Item Scrap, White", + "Common Item", "Uncommon Item", "Legendary Item", "Boss Item", "Lunar Item", + "Void Item", "Equipment") # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled - if not self.multiworld.enable_lunar[self.player] or pool_option == ItemWeights.option_lunartic: + if not self.o.enable_lunar or pool_option == ItemWeights.option_lunartic: junk_pool.pop("Lunar Item") # remove void items from the pool - if not self.multiworld.dlc_sotv[self.player] or pool_option == ItemWeights.option_void: + if not self.o.dlc_sotv or pool_option == ItemWeights.option_void: junk_pool.pop("Void Item") # Generate item pool @@ -131,19 +121,19 @@ def create_items(self) -> None: # precollected environments are popped from the pool so counting like this is valid nonjunk_item_count = self.total_revivals + len(environments_pool) - if self.multiworld.goal[self.player] == "classic": + if self.o.goal == "classic": # classic mode - total_locations = self.multiworld.total_locations[self.player].value + total_locations = self.o.total_locations.value else: # explore mode total_locations = len( orderedstage_location.get_locations( - chests=self.multiworld.chests_per_stage[self.player].value, - shrines=self.multiworld.shrines_per_stage[self.player].value, - scavengers=self.multiworld.scavengers_per_stage[self.player].value, - scanners=self.multiworld.scanner_per_stage[self.player].value, - altars=self.multiworld.altars_per_stage[self.player].value, - dlc_sotv=self.multiworld.dlc_sotv[self.player].value + chests=self.o.chests_per_stage.value, + shrines=self.o.shrines_per_stage.value, + scavengers=self.o.scavengers_per_stage.value, + scanners=self.o.scanner_per_stage.value, + altars=self.o.altars_per_stage.value, + dlc_sotv=self.o.dlc_sotv.value ) ) junk_item_count = total_locations - nonjunk_item_count @@ -160,7 +150,7 @@ def set_rules(self) -> None: def create_regions(self) -> None: - if self.multiworld.goal[self.player] == "classic": + if self.o.goal == "classic": # classic mode menu = create_region(self.multiworld, self.player, "Menu") self.multiworld.regions.append(menu) @@ -169,7 +159,7 @@ def create_regions(self) -> None: victory_region = create_region(self.multiworld, self.player, "Victory") self.multiworld.regions.append(victory_region) petrichor = create_region(self.multiworld, self.player, "Petrichor V", - get_classic_item_pickups(self.multiworld.total_locations[self.player].value)) + get_classic_item_pickups(self.o.total_locations.value)) self.multiworld.regions.append(petrichor) # classic mode can get to victory from the beginning of the game @@ -184,24 +174,23 @@ def create_regions(self) -> None: # explore mode create_regions(self.multiworld, self.player) - self.create_events() + create_events(self.multiworld, self.player) def fill_slot_data(self): + options_dict = self.o.as_dict("item_pickup_step", "shrine_use_step", "goal", "total_locations", + "chests_per_stage", "shrines_per_stage", "scavengers_per_stage", + "scanner_per_stage", "altars_per_stage", "total_revivals", "start_with_revive", + "final_stage_death", "death_link") + cased_dict = {} + for key, value in options_dict.items(): + split_name = [name.title() for name in key.split("_")] + split_name[0] = split_name[0].lower() + new_name = "".join(split_name) + cased_dict[new_name] = value + return { - "itemPickupStep": self.multiworld.item_pickup_step[self.player].value, - "shrineUseStep": self.multiworld.shrine_use_step[self.player].value, - "goal": self.multiworld.goal[self.player].value, + **cased_dict, "seed": "".join(self.multiworld.per_slot_randoms[self.player].choice(string.digits) for _ in range(16)), - "totalLocations": self.multiworld.total_locations[self.player].value, - "chestsPerStage": self.multiworld.chests_per_stage[self.player].value, - "shrinesPerStage": self.multiworld.shrines_per_stage[self.player].value, - "scavengersPerStage": self.multiworld.scavengers_per_stage[self.player].value, - "scannerPerStage": self.multiworld.scanner_per_stage[self.player].value, - "altarsPerStage": self.multiworld.altars_per_stage[self.player].value, - "totalRevivals": self.multiworld.total_revivals[self.player].value, - "startWithDio": self.multiworld.start_with_revive[self.player].value, - "finalStageDeath": self.multiworld.final_stage_death[self.player].value, - "deathLink": self.multiworld.death_link[self.player].value, } def create_item(self, name: str) -> Item: From eaadb6ecc135107b0fc1e5a4bcf36598e73e845e Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 12 Feb 2023 12:59:05 -0600 Subject: [PATCH 017/163] revert the junk pool dict since it's cased differently --- worlds/ror2/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index ed7a2778b0cf..caf4cf54c967 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -100,9 +100,19 @@ def create_items(self) -> None: else: junk_pool = item_pool_weights[pool_option].copy() else: # generate junk pool from user created presets - junk_pool = self.o.as_dict("Item Scrap, Green", "Item Scrap, Red", "Item Scrap, Yellow", "Item Scrap, White", - "Common Item", "Uncommon Item", "Legendary Item", "Boss Item", "Lunar Item", - "Void Item", "Equipment") + junk_pool = { + "Item Scrap, Green": self.o.green_scrap.value, + "Item Scrap, Red": self.o.red_scrap.value, + "Item Scrap, Yellow": self.o.yellow_scrap.value, + "Item Scrap, White": self.o.white_scrap.value, + "Common Item": self.o.common_item.value, + "Uncommon Item": self.o.uncommon_item.value, + "Legendary Item": self.o.legendary_item.value, + "Boss Item": self.o.boss_item.value, + "Lunar Item": self.o.lunar_item.value, + "Void Item": self.o.void_item.value, + "Equipment": self.o.equipment.value + } # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled if not self.o.enable_lunar or pool_option == ItemWeights.option_lunartic: From 0658a5b2f21ebd37487936ad65a3428810988801 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 13 Feb 2023 19:34:06 -0600 Subject: [PATCH 018/163] fix begin_with_loop typo --- worlds/ror2/Options.py | 2 +- worlds/ror2/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py index 84a3c92ac347..caa024d100bd 100644 --- a/worlds/ror2/Options.py +++ b/worlds/ror2/Options.py @@ -300,7 +300,7 @@ class ROR2Options(PerGameCommonOptions, ROR2Weights): total_revivals: TotalRevivals start_with_revive: StartWithRevive final_stage_death: FinalStageDeath - being_with_loop: BeginWithLoop + begin_with_loop: BeginWithLoop dlc_sotv: DLC_SOTV death_link: DeathLink item_pickup_step: ItemPickupStep diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 8306eb693704..4ee137968ea0 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -82,7 +82,7 @@ def create_items(self) -> None: if self.o.dlc_sotv: environment_offset_table = shift_by_offset(environment_sotv_table, environment_offest) environments_pool = {**environments_pool, **environment_offset_table} - environments_to_precollect = 5 if self.o.begin_with_loop.value else 1 + environments_to_precollect = 5 if self.o.begin_with_loop else 1 # percollect environments for each stage (or just stage 1) for i in range(environments_to_precollect): unlock = self.multiworld.random.choices(list(environment_available_orderedstages_table[i].keys()), k=1) From effbca039bf78c67e68d6fc0fefe2bb50e0ad303 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Feb 2023 12:34:24 -0600 Subject: [PATCH 019/163] write new and old options to spoiler --- BaseClasses.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 85d49daf0679..1ed35e0b7a13 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections import copy import itertools import functools @@ -1573,8 +1574,11 @@ def bool_to_text(variable: Union[bool, str]) -> str: return variable return 'Yes' if variable else 'No' - def write_option(option_key: str, option_obj: type(Options.Option)): - res = getattr(self.multiworld, option_key)[player] + def write_option(option_key: str, option_obj: Union[type(Options.Option), "Options.AssembleOptions"]): + if hasattr(self.multiworld.worlds[player].o, option_key): + res = getattr(self.multiworld.worlds[player].o, option_key) + else: # TODO remove when all worlds move to new system + res = getattr(self.multiworld, option_key)[player] display_name = getattr(option_obj, "display_name", option_key) try: outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') @@ -1594,12 +1598,16 @@ def write_option(option_key: str, option_obj: type(Options.Option)): if self.multiworld.players > 1: outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - for f_option, option in Options.per_game_common_options.items(): - write_option(f_option, option) - options = self.multiworld.worlds[player].option_definitions - if options: - for f_option, option in options.items(): + + if self.multiworld.worlds[player].o is not Options.PerGameCommonOptions: + for f_option, option in self.multiworld.worlds[player].o.__annotations__.items(): + write_option(f_option, option) + else: # TODO remove when all worlds move to new system + options = self.multiworld.worlds[player].option_definitions + for f_option, option\ + in collections.ChainMap(Options.PerGameCommonOptions.__annotations__.items(), options): write_option(f_option, option) + AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) if player in self.multiworld.get_game_players("A Link to the Past"): From b78b7d383bb7c725e1401e2d352f9db5d17ca6d0 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Feb 2023 12:35:14 -0600 Subject: [PATCH 020/163] change factorio option behavior back --- worlds/factorio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index 03c997cc2c93..e691ac61c908 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -73,8 +73,8 @@ def __init__(self, world, player: int): generate_output = generate_mod def generate_early(self) -> None: - self.multiworld.max_tech_cost[self.player].value = max(self.multiworld.max_tech_cost[self.player].value, - self.multiworld.min_tech_cost[self.player].value) + self.multiworld.max_tech_cost[self.player] = max(self.multiworld.max_tech_cost[self.player], + self.multiworld.min_tech_cost[self.player]) self.tech_mix = self.multiworld.tech_cost_mix[self.player] self.skip_silo = self.multiworld.silo[self.player].value == Silo.option_spawn From 94d18dc82c67aefafceac7d1c37807d0683220e7 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Feb 2023 13:24:48 -0600 Subject: [PATCH 021/163] fix comparisons --- BaseClasses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 1ed35e0b7a13..c9193a5d807d 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1599,13 +1599,13 @@ def write_option(option_key: str, option_obj: Union[type(Options.Option), "Optio outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - if self.multiworld.worlds[player].o is not Options.PerGameCommonOptions: + if type(self.multiworld.worlds[player].o) is not Options.PerGameCommonOptions: for f_option, option in self.multiworld.worlds[player].o.__annotations__.items(): write_option(f_option, option) else: # TODO remove when all worlds move to new system options = self.multiworld.worlds[player].option_definitions for f_option, option\ - in collections.ChainMap(Options.PerGameCommonOptions.__annotations__.items(), options): + in collections.ChainMap(Options.PerGameCommonOptions.__annotations__, options).items(): write_option(f_option, option) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) From 0c0663bc46e1ec1d06805d05735102f9172d2e31 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Feb 2023 13:39:56 -0600 Subject: [PATCH 022/163] move common and per_game_common options to new system --- BaseClasses.py | 2 +- Fill.py | 8 ++++---- Main.py | 22 +++++++++++----------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index c9193a5d807d..6575178311ec 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -247,7 +247,7 @@ def set_item_links(self): item_links = {} replacement_prio = [False, True, None] for player in self.player_ids: - for item_link in self.item_links[player].value: + for item_link in self.worlds[player].o.item_links.value: if item_link["name"] in item_links: if item_links[item_link["name"]]["game"] != self.game[player]: raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}") diff --git a/Fill.py b/Fill.py index 725d1a41d090..7ee5b5be5999 100644 --- a/Fill.py +++ b/Fill.py @@ -221,7 +221,7 @@ def fast_fill(world: MultiWorld, def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"} + minimal_players = {player for player in world.player_ids if world.worlds[player].o.accessibility == "minimal"} unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: @@ -244,7 +244,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal') + return not ((item.classification & 0b0011) and world.worlds[item.player].o.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) @@ -487,9 +487,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None: # If other players are below the threshold value, swap progression in this sphere into earlier spheres, # which gives more locations available by this sphere. balanceable_players: typing.Dict[int, float] = { - player: world.progression_balancing[player] / 100 + player: world.worlds[player].o.progression_balancing / 100 for player in world.player_ids - if world.progression_balancing[player] > 0 + if world.worlds[player].o.progression_balancing > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') diff --git a/Main.py b/Main.py index 04a7e3bff60b..7fa88ed8aa3f 100644 --- a/Main.py +++ b/Main.py @@ -113,7 +113,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info('') for player in world.player_ids: - for item_name, count in world.start_inventory[player].value.items(): + for item_name, count in world.worlds[player].o.start_inventory.value.items(): for _ in range(count): world.push_precollected(world.create_item(item_name, player)) @@ -131,21 +131,21 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player in world.player_ids: # items can't be both local and non-local, prefer local - world.non_local_items[player].value -= world.local_items[player].value - world.non_local_items[player].value -= set(world.local_early_items[player]) + world.worlds[player].o.non_local_items.value -= world.worlds[player].o.local_items.value + world.worlds[player].o.non_local_items.value -= set(world.local_early_items[player]) if world.players > 1: locality_rules(world) else: - world.non_local_items[1].value = set() - world.local_items[1].value = set() + world.worlds[1].o.non_local_items.value = set() + world.worlds[1].o.local_items.value = set() AutoWorld.call_all(world, "set_rules") for player in world.player_ids: - exclusion_rules(world, player, world.exclude_locations[player].value) - world.priority_locations[player].value -= world.exclude_locations[player].value - for location_name in world.priority_locations[player].value: + exclusion_rules(world, player, world.worlds[player].o.exclude_locations.value) + world.worlds[player].o.priority_locations.value -= world.worlds[player].o.exclude_locations.value + for location_name in world.worlds[player].o.priority_locations.value: world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY AutoWorld.call_all(world, "generate_basic") @@ -348,11 +348,11 @@ def precollect_hint(location): f" {location}" locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags - if location.name in world.start_location_hints[location.player]: + if location.name in world.worlds[location.player].o.start_location_hints: precollect_hint(location) - elif location.item.name in world.start_hints[location.item.player]: + elif location.item.name in world.worlds[location.item.player].o.start_hints: precollect_hint(location) - elif any([location.item.name in world.start_hints[player] + elif any([location.item.name in world.worlds[player].o.start_hints for player in world.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) From a6385827ec3423f0d286d2076cdd65e2562d1733 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Tue, 14 Feb 2023 22:55:56 +0100 Subject: [PATCH 023/163] core: automatically create missing options_dataclass from legacy option_definitions --- worlds/AutoWorld.py | 7 +++++++ worlds/alttp/test/inverted/TestInvertedBombRules.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 2af3969061d3..bff9e7cf2bc8 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -3,6 +3,7 @@ import logging import sys import pathlib +from dataclasses import make_dataclass from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING, \ ClassVar @@ -44,6 +45,12 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut 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: + dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(), + bases=(PerGameCommonOptions,)) + # construct class new_class = super().__new__(mcs, name, bases, dct) if "game" in dct: diff --git a/worlds/alttp/test/inverted/TestInvertedBombRules.py b/worlds/alttp/test/inverted/TestInvertedBombRules.py index 1bb8a79be767..c3bdb5ffd468 100644 --- a/worlds/alttp/test/inverted/TestInvertedBombRules.py +++ b/worlds/alttp/test/inverted/TestInvertedBombRules.py @@ -19,7 +19,7 @@ def setUp(self): args = self.multiworld.default_common_options for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.multiworld.set_options(args) self.multiworld.difficulty_requirements[1] = difficulties['normal'] create_inverted_regions(self.multiworld, 1) create_dungeons(self.multiworld, 1) From fe679fce154eae0dfb9d3d0c6296c0f006dc5798 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Feb 2023 17:21:45 -0600 Subject: [PATCH 024/163] remove spoiler special casing and add back the Factorio option changing but in new system --- BaseClasses.py | 15 +++------------ worlds/factorio/__init__.py | 7 +++---- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 6575178311ec..b0b4118fd0da 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1575,10 +1575,7 @@ def bool_to_text(variable: Union[bool, str]) -> str: return 'Yes' if variable else 'No' def write_option(option_key: str, option_obj: Union[type(Options.Option), "Options.AssembleOptions"]): - if hasattr(self.multiworld.worlds[player].o, option_key): - res = getattr(self.multiworld.worlds[player].o, option_key) - else: # TODO remove when all worlds move to new system - res = getattr(self.multiworld, option_key)[player] + res = getattr(self.multiworld.worlds[player].o, option_key) display_name = getattr(option_obj, "display_name", option_key) try: outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') @@ -1599,14 +1596,8 @@ def write_option(option_key: str, option_obj: Union[type(Options.Option), "Optio outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - if type(self.multiworld.worlds[player].o) is not Options.PerGameCommonOptions: - for f_option, option in self.multiworld.worlds[player].o.__annotations__.items(): - write_option(f_option, option) - else: # TODO remove when all worlds move to new system - options = self.multiworld.worlds[player].option_definitions - for f_option, option\ - in collections.ChainMap(Options.PerGameCommonOptions.__annotations__, options).items(): - write_option(f_option, option) + for f_option, option in self.multiworld.worlds[player].o.__annotations__.items(): + write_option(f_option, option) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index e691ac61c908..a25e084819b8 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -73,10 +73,9 @@ def __init__(self, world, player: int): generate_output = generate_mod def generate_early(self) -> None: - self.multiworld.max_tech_cost[self.player] = max(self.multiworld.max_tech_cost[self.player], - self.multiworld.min_tech_cost[self.player]) - self.tech_mix = self.multiworld.tech_cost_mix[self.player] - self.skip_silo = self.multiworld.silo[self.player].value == Silo.option_spawn + self.o.max_tech_cost = max(self.o.max_tech_cost, self.o.min_tech_cost) + self.tech_mix = self.o.tech_cost_mix + self.skip_silo = self.o.silo.value == Silo.option_spawn def create_regions(self): player = self.player From c5684bb3b628e221245c0f3cbb6d259e739c3fb5 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Feb 2023 19:29:32 -0600 Subject: [PATCH 025/163] give ArchipIDLE the default options_dataclass so its options get generated and spoilered properly --- worlds/archipidle/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/worlds/archipidle/__init__.py b/worlds/archipidle/__init__.py index 5054872dbec3..77d9222d0c45 100644 --- a/worlds/archipidle/__init__.py +++ b/worlds/archipidle/__init__.py @@ -1,4 +1,5 @@ from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification +from Options import PerGameCommonOptions from .Items import item_table from .Rules import set_rules from ..AutoWorld import World, WebWorld @@ -29,6 +30,8 @@ class ArchipIDLEWorld(World): hidden = (datetime.now().month != 4) # ArchipIDLE is only visible during April web = ArchipIDLEWebWorld() + options_dataclass = PerGameCommonOptions + item_name_to_id = {} start_id = 9000 for item in item_table: From cecd3f725b263051d38694a488592e2c91bf8400 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 16 Feb 2023 23:22:04 -0600 Subject: [PATCH 026/163] reimplement `inspect.get_annotations` --- Utils.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/Utils.py b/Utils.py index 133f1c452e06..beccf1336f4b 100644 --- a/Utils.py +++ b/Utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import types import typing import builtins import os @@ -682,6 +683,75 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray: _faf_tasks: "Set[asyncio.Task[None]]" = set() +def get_annotations(obj, *, globals=None, locals=None, eval_str=False): + """ + Shamelessly copy pasted implementation of `inspect.get_annotations` from Python 3.10. + TODO remove this once Python 3.8 and 3.9 support is dropped and replace with `inspect.get_annotations` + """ + if isinstance(obj, type): + # class + obj_dict = getattr(obj, "__dict__", None) + if obj_dict and hasattr(obj_dict, "get"): + ann = obj_dict.get("__annotations__", None) + if isinstance(ann, types.GetSetDescriptorType): + ann = None + else: + ann = None + + obj_globals = None + module_name = getattr(obj, "__module__", None) + if module_name: + module = sys.modules.get(module_name, None) + if module_name: + module = sys.modules.get(module_name, None) + if module: + obj_globals = getattr(module, "__dict__", None) + obj_locals = dict(vars(obj)) + unwrap = obj + elif isinstance(obj, types.ModuleType): + # module + ann = getattr(obj, "__annotations__", None) + obj_globals = getattr(obj, "__dict__") + obj_locals = None + unwrap = None + elif callable(obj): + ann = getattr(obj, "__annotations__", None) + obj_globals = getattr(obj, "__globals__", None) + obj_locals = None + unwrap = obj + else: + raise TypeError(f"{obj!r} is not a module, class, or callable") + + if ann is None: + return {} + if not isinstance(ann, dict): + raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") + if not ann: + return {} + if not eval_str: + return dict(ann) + if unwrap is not None: + while True: + if hasattr(unwrap, "__wrapped__"): + unwrap = unwrap.__wrapped__ + continue + if isinstance(unwrap, functools.partial): + unwrap = unwrap.func + continue + break + if hasattr(unwrap, "__globals__"): + obj_globals = unwrap.__globals__ + + if globals is None: + globals = obj_globals + if locals is None: + locals = obj_locals + + return_value = {key: value if not isinstance(value, str) else eval(value, globals, locals) + for key, value in ann.items()} + return return_value + + def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None: """ Use this to start a task when you don't keep a reference to it or immediately await it, From 1fbc1a4f32bc798a4aa350690cb2f8ef9a85a350 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 16 Feb 2023 23:22:50 -0600 Subject: [PATCH 027/163] move option info generation for webhost to new system --- WebHostLib/options.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 8f366d4fbf31..a5d56780398c 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -7,7 +7,7 @@ from jinja2 import Template import Options -from Utils import __version__, local_path +from Utils import __version__, local_path, get_annotations from worlds.AutoWorld import AutoWorldRegister handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", @@ -58,8 +58,8 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for game_name, world in AutoWorldRegister.world_types.items(): all_options: typing.Dict[str, Options.AssembleOptions] = { - **Options.per_game_common_options, - **world.option_definitions + **get_annotations(Options.PerGameCommonOptions, eval_str=True), + **get_annotations(world.options_dataclass, eval_str=True) } with open(local_path("WebHostLib", "templates", "options.yaml")) as f: file_data = f.read() From c3ad00b8d9e4a9085b38ae4a75c8e001c20abc8f Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 16 Feb 2023 23:30:02 -0600 Subject: [PATCH 028/163] need to include Common and PerGame common since __annotations__ doesn't include super --- WebHostLib/options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index a5d56780398c..346bd1b3b624 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -58,6 +58,7 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for game_name, world in AutoWorldRegister.world_types.items(): all_options: typing.Dict[str, Options.AssembleOptions] = { + **get_annotations(Options.CommonOptions, eval_str=True), **get_annotations(Options.PerGameCommonOptions, eval_str=True), **get_annotations(world.options_dataclass, eval_str=True) } From 1b1ee314c80605fd775b362a0709080a77a8f122 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 21 Feb 2023 08:47:43 -0600 Subject: [PATCH 029/163] use get_type_hints for the options dictionary --- Utils.py | 69 ------------------------------------------- WebHostLib/options.py | 8 ++--- 2 files changed, 4 insertions(+), 73 deletions(-) diff --git a/Utils.py b/Utils.py index beccf1336f4b..777c3283935c 100644 --- a/Utils.py +++ b/Utils.py @@ -683,75 +683,6 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray: _faf_tasks: "Set[asyncio.Task[None]]" = set() -def get_annotations(obj, *, globals=None, locals=None, eval_str=False): - """ - Shamelessly copy pasted implementation of `inspect.get_annotations` from Python 3.10. - TODO remove this once Python 3.8 and 3.9 support is dropped and replace with `inspect.get_annotations` - """ - if isinstance(obj, type): - # class - obj_dict = getattr(obj, "__dict__", None) - if obj_dict and hasattr(obj_dict, "get"): - ann = obj_dict.get("__annotations__", None) - if isinstance(ann, types.GetSetDescriptorType): - ann = None - else: - ann = None - - obj_globals = None - module_name = getattr(obj, "__module__", None) - if module_name: - module = sys.modules.get(module_name, None) - if module_name: - module = sys.modules.get(module_name, None) - if module: - obj_globals = getattr(module, "__dict__", None) - obj_locals = dict(vars(obj)) - unwrap = obj - elif isinstance(obj, types.ModuleType): - # module - ann = getattr(obj, "__annotations__", None) - obj_globals = getattr(obj, "__dict__") - obj_locals = None - unwrap = None - elif callable(obj): - ann = getattr(obj, "__annotations__", None) - obj_globals = getattr(obj, "__globals__", None) - obj_locals = None - unwrap = obj - else: - raise TypeError(f"{obj!r} is not a module, class, or callable") - - if ann is None: - return {} - if not isinstance(ann, dict): - raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") - if not ann: - return {} - if not eval_str: - return dict(ann) - if unwrap is not None: - while True: - if hasattr(unwrap, "__wrapped__"): - unwrap = unwrap.__wrapped__ - continue - if isinstance(unwrap, functools.partial): - unwrap = unwrap.func - continue - break - if hasattr(unwrap, "__globals__"): - obj_globals = unwrap.__globals__ - - if globals is None: - globals = obj_globals - if locals is None: - locals = obj_locals - - return_value = {key: value if not isinstance(value, str) else eval(value, globals, locals) - for key, value in ann.items()} - return return_value - - def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None: """ Use this to start a task when you don't keep a reference to it or immediately await it, diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 346bd1b3b624..b2ce6df2de50 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -7,7 +7,7 @@ from jinja2 import Template import Options -from Utils import __version__, local_path, get_annotations +from Utils import __version__, local_path from worlds.AutoWorld import AutoWorldRegister handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", @@ -58,9 +58,9 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for game_name, world in AutoWorldRegister.world_types.items(): all_options: typing.Dict[str, Options.AssembleOptions] = { - **get_annotations(Options.CommonOptions, eval_str=True), - **get_annotations(Options.PerGameCommonOptions, eval_str=True), - **get_annotations(world.options_dataclass, eval_str=True) + **typing.get_type_hints(Options.CommonOptions), + **typing.get_type_hints(Options.PerGameCommonOptions), + **typing.get_type_hints(world.options_dataclass) } with open(local_path("WebHostLib", "templates", "options.yaml")) as f: file_data = f.read() From 85e98a0471a2c38494499901a484c1788a987471 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 21 Feb 2023 08:49:36 -0600 Subject: [PATCH 030/163] typing.get_type_hints returns the bases too. --- WebHostLib/options.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index b2ce6df2de50..117f57549b63 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -57,11 +57,8 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for game_name, world in AutoWorldRegister.world_types.items(): - all_options: typing.Dict[str, Options.AssembleOptions] = { - **typing.get_type_hints(Options.CommonOptions), - **typing.get_type_hints(Options.PerGameCommonOptions), - **typing.get_type_hints(world.options_dataclass) - } + all_options: typing.Dict[str, Options.AssembleOptions] = typing.get_type_hints(world.options_dataclass) + with open(local_path("WebHostLib", "templates", "options.yaml")) as f: file_data = f.read() res = Template(file_data).render( From a24bb2e5d8d60b6ea5f109f5ae70ba31630b32d6 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 21 Feb 2023 09:34:26 -0600 Subject: [PATCH 031/163] forgot to sweep through generate --- Generate.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Generate.py b/Generate.py index dadabd7ac6ec..46784418ac1a 100644 --- a/Generate.py +++ b/Generate.py @@ -8,7 +8,7 @@ import urllib.parse import urllib.request from collections import Counter, ChainMap -from typing import Dict, Tuple, Callable, Any, Union +from typing import Dict, Tuple, Callable, Any, Union, get_type_hints import ModuleUpdate @@ -339,7 +339,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: return get_choice(option_key, category_dict) if game in AutoWorldRegister.world_types: game_world = AutoWorldRegister.world_types[game] - options = ChainMap(game_world.option_definitions, Options.per_game_common_options) + options = get_type_hints(game_world.options_dataclass) if option_key in options: if options[option_key].supports_weighting: return get_choice(option_key, category_dict) @@ -464,13 +464,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) if ret.game in AutoWorldRegister.world_types: - for option_key, option in world_type.option_definitions.items(): + for option_key, option in get_type_hints(world_type.options_dataclass).items(): handle_option(ret, game_weights, option_key, option, plando_options) - for option_key, option in Options.per_game_common_options.items(): - # skip setting this option if already set from common_options, defaulting to root option - if option_key not in world_type.option_definitions and \ - (option_key not in Options.common_options or option_key in game_weights): - handle_option(ret, game_weights, option_key, option, plando_options) if PlandoOptions.items in plando_options: ret.plando_items = game_weights.get("plando_items", []) if ret.game == "Minecraft" or ret.game == "Ocarina of Time": From 9df025daeb3936cf0349bb0cf42d0bb13b0a43d9 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 21 Feb 2023 09:34:38 -0600 Subject: [PATCH 032/163] sweep through all the tests --- BaseClasses.py | 12 +--------- test/TestBase.py | 5 +++-- test/general/TestFill.py | 18 ++++++++------- test/general/__init__.py | 8 ++++--- worlds/alttp/test/__init__.py | 16 ++++++++++++++ worlds/alttp/test/dungeons/TestDungeon.py | 16 ++++---------- worlds/alttp/test/inverted/TestInverted.py | 14 ++++-------- .../test/inverted/TestInvertedBombRules.py | 14 +++--------- .../TestInvertedMinor.py | 17 +++++--------- .../test/inverted_owg/TestInvertedOWG.py | 16 ++++---------- worlds/alttp/test/minor_glitches/TestMinor.py | 18 ++++----------- worlds/alttp/test/owg/TestVanillaOWG.py | 21 +++++------------- worlds/alttp/test/vanilla/TestVanilla.py | 22 +++++-------------- 13 files changed, 70 insertions(+), 127 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index b0b4118fd0da..10ad846432e1 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -234,8 +234,7 @@ def set_options(self, args: Namespace) -> None: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) - for option_key in itertools.chain(Options.common_options, Options.per_game_common_options, - world_type.option_definitions): + for option_key in typing.get_type_hints(world_type.options_dataclass): option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) # TODO - remove this loop once all worlds use options dataclasses @@ -302,15 +301,6 @@ def set_item_links(self): group["non_local_items"] = item_link["non_local_items"] group["link_replacement"] = replacement_prio[item_link["link_replacement"]] - # intended for unittests - @property - def default_common_options(self) -> Namespace: - self.state = CollectionState(self) - args = Namespace() - for option_key, option in itertools.chain(Options.common_options.items(), Options.per_game_common_options.items()): - setattr(args, option_key, {player_id: option.from_any(option.default) for player_id in self.player_ids}) - return args - def secure(self): self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.is_race = True diff --git a/test/TestBase.py b/test/TestBase.py index c4902d4799ce..220ecc929909 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -118,8 +118,9 @@ def world_setup(self, seed: typing.Optional[int] = None) -> None: self.multiworld.game[1] = self.game self.multiworld.player_name = {1: "Tester"} self.multiworld.set_seed(seed) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items(): + self.multiworld.state = CollectionState(self.multiworld) + args = Namespace() + for name, option in typing.get_type_hints(AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass).items(): setattr(args, name, { 1: option.from_any(self.options.get(name, getattr(option, "default"))) }) diff --git a/test/general/TestFill.py b/test/general/TestFill.py index 927267521f8a..5b9b1d9acaa8 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -1,4 +1,5 @@ import itertools +from argparse import Namespace from typing import get_type_hints, List, Iterable import unittest @@ -8,14 +9,14 @@ from Fill import FillError, balance_multiworld_progression, fill_restrictive, \ distribute_early_items, distribute_items_restrictive from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, Item, Location, \ - ItemClassification + ItemClassification, CollectionState from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule def generate_multi_world(players: int = 1) -> MultiWorld: multi_world = MultiWorld(players) multi_world.player_name = {} - args = multi_world.default_common_options + multi_world.state = CollectionState(multi_world) for i in range(players): player_id = i+1 world = World(multi_world, player_id) @@ -24,12 +25,13 @@ def generate_multi_world(players: int = 1) -> MultiWorld: multi_world.player_name[player_id] = "Test Player " + str(player_id) region = Region("Menu", player_id, multi_world, "Menu Region Hint") multi_world.regions.append(region) - - for option_key in itertools.chain(Options.common_options, Options.per_game_common_options): - option_value = getattr(args, option_key, {}) - setattr(multi_world, option_key, option_value) - # TODO - remove this loop once all worlds use options dataclasses - world.o = world.options_dataclass(**{option_key: getattr(args, option_key)[player_id] + for option_key, option in get_type_hints(Options.PerGameCommonOptions).items(): + if hasattr(multi_world, option_key): + getattr(multi_world, option_key).setdefault(player_id, option.from_any(getattr(option, "default"))) + else: + setattr(multi_world, option_key, {player_id: option.from_any(getattr(option, "default"))}) + # TODO - remove this loop once all worlds use options dataclasses + world.o = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id] for option_key in get_type_hints(world.options_dataclass)}) multi_world.set_seed(0) diff --git a/test/general/__init__.py b/test/general/__init__.py index f985989dbba7..b6bede376fd7 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -1,6 +1,7 @@ from argparse import Namespace +from typing import get_type_hints -from BaseClasses import MultiWorld +from BaseClasses import MultiWorld, CollectionState from worlds.AutoWorld import call_all gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"] @@ -11,8 +12,9 @@ def setup_solo_multiworld(world_type) -> MultiWorld: multiworld.game[1] = world_type.game multiworld.player_name = {1: "Tester"} multiworld.set_seed() - args = multiworld.default_common_options - for name, option in world_type.option_definitions.items(): + multiworld.state = CollectionState(multiworld) + args = Namespace() + for name, option in get_type_hints(world_type.options_dataclass).items(): setattr(args, name, {1: option.from_any(option.default)}) multiworld.set_options(args) for step in gen_steps: diff --git a/worlds/alttp/test/__init__.py b/worlds/alttp/test/__init__.py index e69de29bb2d1..4efc3723ea5e 100644 --- a/worlds/alttp/test/__init__.py +++ b/worlds/alttp/test/__init__.py @@ -0,0 +1,16 @@ +import unittest +from argparse import Namespace +from typing import get_type_hints + +from BaseClasses import MultiWorld, CollectionState +from worlds import AutoWorldRegister + + +class LTTPTestBase(unittest.TestCase): + def world_setup(self): + self.multiworld = MultiWorld(1) + self.multiworld.state = CollectionState(self.multiworld) + args = Namespace() + for name, option in get_type_hints(AutoWorldRegister.world_types["A Link to the Past"].options_dataclass).items(): + setattr(args, name, {1: option.from_any(getattr(option, "default"))}) + self.multiworld.set_options(args) diff --git a/worlds/alttp/test/dungeons/TestDungeon.py b/worlds/alttp/test/dungeons/TestDungeon.py index 9641f2ce8ec4..7397c02100af 100644 --- a/worlds/alttp/test/dungeons/TestDungeon.py +++ b/worlds/alttp/test/dungeons/TestDungeon.py @@ -1,24 +1,16 @@ -import unittest -from argparse import Namespace - -from BaseClasses import MultiWorld, CollectionState, ItemClassification +from BaseClasses import CollectionState, ItemClassification from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import mandatory_connections, connect_simple from worlds.alttp.ItemPool import difficulties, generate_itempool from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import create_regions from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestDungeon(unittest.TestCase): +class TestDungeon(LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.starting_regions = [] # Where to start exploring self.remove_exits = [] # Block dungeon exits self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/inverted/TestInverted.py b/worlds/alttp/test/inverted/TestInverted.py index 95de61da648e..7d675d9dccb6 100644 --- a/worlds/alttp/test/inverted/TestInverted.py +++ b/worlds/alttp/test/inverted/TestInverted.py @@ -1,6 +1,3 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_inverted_entrances from worlds.alttp.InvertedRegions import create_inverted_regions @@ -10,15 +7,12 @@ from worlds.alttp.Shops import create_shops from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase + -class TestInverted(TestBase): +class TestInverted(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.mode[1] = "inverted" create_inverted_regions(self.multiworld, 1) diff --git a/worlds/alttp/test/inverted/TestInvertedBombRules.py b/worlds/alttp/test/inverted/TestInvertedBombRules.py index c3bdb5ffd468..df31dafecc7e 100644 --- a/worlds/alttp/test/inverted/TestInvertedBombRules.py +++ b/worlds/alttp/test/inverted/TestInvertedBombRules.py @@ -1,25 +1,17 @@ -import unittest -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances, Inverted_LW_Dungeon_Entrances, Inverted_LW_Single_Cave_Doors, Inverted_Old_Man_Entrances, Inverted_DW_Entrances, Inverted_DW_Dungeon_Entrances, Inverted_DW_Single_Cave_Doors, \ Inverted_LW_Entrances_Must_Exit, Inverted_LW_Dungeon_Entrances_Must_Exit, Inverted_Bomb_Shop_Multi_Cave_Doors, Inverted_Bomb_Shop_Single_Cave_Doors, Blacksmith_Single_Cave_Doors, Inverted_Blacksmith_Multi_Cave_Doors from worlds.alttp.InvertedRegions import create_inverted_regions from worlds.alttp.ItemPool import difficulties from worlds.alttp.Rules import set_inverted_big_bomb_rules -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestInvertedBombRules(unittest.TestCase): +class TestInvertedBombRules(LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) + self.world_setup() self.multiworld.mode[1] = "inverted" - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) self.multiworld.difficulty_requirements[1] = difficulties['normal'] create_inverted_regions(self.multiworld, 1) create_dungeons(self.multiworld, 1) diff --git a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py index f9068da60a8e..1368b063a22f 100644 --- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py +++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py @@ -1,25 +1,18 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_inverted_entrances from worlds.alttp.InvertedRegions import create_inverted_regions -from worlds.alttp.ItemPool import generate_itempool, difficulties +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import mark_light_world_regions from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase + -class TestInvertedMinor(TestBase): +class TestInvertedMinor(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.multiworld.mode[1] = "inverted" self.multiworld.logic[1] = "minorglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py index 78f008680a55..77698b863a09 100644 --- a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py +++ b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py @@ -1,26 +1,18 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool from worlds.alttp.EntranceShuffle import link_inverted_entrances from worlds.alttp.InvertedRegions import create_inverted_regions -from worlds.alttp.ItemPool import generate_itempool, difficulties +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory from worlds.alttp.Regions import mark_light_world_regions from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestInvertedOWG(TestBase): +class TestInvertedOWG(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.multiworld.logic[1] = "owglitches" self.multiworld.mode[1] = "inverted" self.multiworld.difficulty_requirements[1] = difficulties['normal'] diff --git a/worlds/alttp/test/minor_glitches/TestMinor.py b/worlds/alttp/test/minor_glitches/TestMinor.py index a0a87e913eeb..d5cfd3095b9c 100644 --- a/worlds/alttp/test/minor_glitches/TestMinor.py +++ b/worlds/alttp/test/minor_glitches/TestMinor.py @@ -1,25 +1,15 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld -from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool -from worlds.alttp.EntranceShuffle import link_entrances +from worlds.alttp.Dungeons import get_dungeon_item_pool from worlds.alttp.InvertedRegions import mark_dark_world_regions from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory -from worlds.alttp.Regions import create_regions -from worlds.alttp.Shops import create_shops from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestMinor(TestBase): +class TestMinor(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.multiworld.logic[1] = "minorglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/alttp/test/owg/TestVanillaOWG.py b/worlds/alttp/test/owg/TestVanillaOWG.py index fa288ea5b44e..37b0b6ccb868 100644 --- a/worlds/alttp/test/owg/TestVanillaOWG.py +++ b/worlds/alttp/test/owg/TestVanillaOWG.py @@ -1,26 +1,15 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld -from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool -from worlds.alttp.EntranceShuffle import link_entrances +from worlds.alttp.Dungeons import get_dungeon_item_pool from worlds.alttp.InvertedRegions import mark_dark_world_regions -from worlds.alttp.ItemPool import difficulties, generate_itempool +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory -from worlds.alttp.Regions import create_regions -from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase -class TestVanillaOWG(TestBase): +class TestVanillaOWG(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.logic[1] = "owglitches" self.multiworld.worlds[1].er_seed = 0 diff --git a/worlds/alttp/test/vanilla/TestVanilla.py b/worlds/alttp/test/vanilla/TestVanilla.py index c2352ebb20e5..3c983e98504c 100644 --- a/worlds/alttp/test/vanilla/TestVanilla.py +++ b/worlds/alttp/test/vanilla/TestVanilla.py @@ -1,24 +1,14 @@ -from argparse import Namespace - -from BaseClasses import MultiWorld -from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool -from worlds.alttp.EntranceShuffle import link_entrances +from worlds.alttp.Dungeons import get_dungeon_item_pool from worlds.alttp.InvertedRegions import mark_dark_world_regions -from worlds.alttp.ItemPool import difficulties, generate_itempool +from worlds.alttp.ItemPool import difficulties from worlds.alttp.Items import ItemFactory -from worlds.alttp.Regions import create_regions -from worlds.alttp.Shops import create_shops -from worlds.alttp.Rules import set_rules from test.TestBase import TestBase -from worlds import AutoWorld +from worlds.alttp.test import LTTPTestBase + -class TestVanilla(TestBase): +class TestVanilla(TestBase, LTTPTestBase): def setUp(self): - self.multiworld = MultiWorld(1) - args = self.multiworld.default_common_options - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): - setattr(args, name, {1: option.from_any(option.default)}) - self.multiworld.set_options(args) + self.world_setup() self.multiworld.logic[1] = "noglitches" self.multiworld.difficulty_requirements[1] = difficulties['normal'] self.multiworld.worlds[1].er_seed = 0 From 337133c61174529e02750446088bdb3e2117da92 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 22 Feb 2023 16:46:04 -0600 Subject: [PATCH 033/163] swap to a metaclass property --- BaseClasses.py | 2 +- Generate.py | 8 ++++---- Options.py | 9 ++++++++- WebHostLib/options.py | 2 +- test/TestBase.py | 2 +- worlds/alttp/test/__init__.py | 3 +-- worlds/ror2/__init__.py | 1 - 7 files changed, 16 insertions(+), 11 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 10ad846432e1..ad50dcca85d1 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -200,7 +200,7 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu self.player_types[new_id] = NetUtils.SlotType.group self._region_cache[new_id] = {} world_type = AutoWorld.AutoWorldRegister.world_types[game] - for option_key, option in world_type.option_definitions.items(): + for option_key, option in world_type.options_dataclass.type_hints.items(): getattr(self, option_key)[new_id] = option(option.default) for option_key, option in Options.common_options.items(): getattr(self, option_key)[new_id] = option(option.default) diff --git a/Generate.py b/Generate.py index 46784418ac1a..75f47e79b0c5 100644 --- a/Generate.py +++ b/Generate.py @@ -7,8 +7,8 @@ import string import urllib.parse import urllib.request -from collections import Counter, ChainMap -from typing import Dict, Tuple, Callable, Any, Union, get_type_hints +from collections import Counter +from typing import Dict, Tuple, Callable, Any, Union import ModuleUpdate @@ -339,7 +339,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: return get_choice(option_key, category_dict) if game in AutoWorldRegister.world_types: game_world = AutoWorldRegister.world_types[game] - options = get_type_hints(game_world.options_dataclass) + options = game_world.options_dataclass.type_hints if option_key in options: if options[option_key].supports_weighting: return get_choice(option_key, category_dict) @@ -464,7 +464,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) if ret.game in AutoWorldRegister.world_types: - for option_key, option in get_type_hints(world_type.options_dataclass).items(): + for option_key, option in world_type.options_dataclass.type_hints.items(): handle_option(ret, game_weights, option_key, option, plando_options) if PlandoOptions.items in plando_options: ret.plando_items = game_weights.get("plando_items", []) diff --git a/Options.py b/Options.py index 31956b766733..9a48986b35c0 100644 --- a/Options.py +++ b/Options.py @@ -861,8 +861,15 @@ class ProgressionBalancing(SpecialRange): } +class OptionsMetaProperty(type): + @property + def type_hints(cls) -> typing.Dict[str, AssembleOptions]: + """Returns type hints of the class as a dictionary.""" + return typing.get_type_hints(cls) + + @dataclass -class CommonOptions: +class CommonOptions(metaclass=OptionsMetaProperty): progression_balancing: ProgressionBalancing accessibility: Accessibility diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 117f57549b63..50e123151a4f 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -57,7 +57,7 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for game_name, world in AutoWorldRegister.world_types.items(): - all_options: typing.Dict[str, Options.AssembleOptions] = typing.get_type_hints(world.options_dataclass) + all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints with open(local_path("WebHostLib", "templates", "options.yaml")) as f: file_data = f.read() diff --git a/test/TestBase.py b/test/TestBase.py index 220ecc929909..983b20a1632f 100644 --- a/test/TestBase.py +++ b/test/TestBase.py @@ -120,7 +120,7 @@ def world_setup(self, seed: typing.Optional[int] = None) -> None: self.multiworld.set_seed(seed) self.multiworld.state = CollectionState(self.multiworld) args = Namespace() - for name, option in typing.get_type_hints(AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass).items(): + for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items(): setattr(args, name, { 1: option.from_any(self.options.get(name, getattr(option, "default"))) }) diff --git a/worlds/alttp/test/__init__.py b/worlds/alttp/test/__init__.py index 4efc3723ea5e..958b92b72567 100644 --- a/worlds/alttp/test/__init__.py +++ b/worlds/alttp/test/__init__.py @@ -1,6 +1,5 @@ import unittest from argparse import Namespace -from typing import get_type_hints from BaseClasses import MultiWorld, CollectionState from worlds import AutoWorldRegister @@ -11,6 +10,6 @@ def world_setup(self): self.multiworld = MultiWorld(1) self.multiworld.state = CollectionState(self.multiworld) args = Namespace() - for name, option in get_type_hints(AutoWorldRegister.world_types["A Link to the Past"].options_dataclass).items(): + for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items(): setattr(args, name, {1: option.from_any(getattr(option, "default"))}) self.multiworld.set_options(args) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 4ee137968ea0..4b5c7bc00667 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -30,7 +30,6 @@ class RiskOfRainWorld(World): first crash landing. """ game = "Risk of Rain 2" - option_definitions = get_type_hints(ROR2Options) options_dataclass = ROR2Options o: ROR2Options topology_present = False From aadbd569e4f18f1ec2d893fd581e4fae77e96ba3 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Feb 2023 13:29:05 +0100 Subject: [PATCH 034/163] move remaining usages from get_type_hints to metaclass property --- BaseClasses.py | 4 ++-- test/general/TestFill.py | 8 +++----- test/general/__init__.py | 8 ++++---- worlds/ror2/__init__.py | 1 - 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index ad50dcca85d1..5e94e1de3490 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -234,13 +234,13 @@ def set_options(self, args: Namespace) -> None: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) - for option_key in typing.get_type_hints(world_type.options_dataclass): + for option_key in world_type.options_dataclass.type_hints: option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) # TODO - remove this loop once all worlds use options dataclasses options_dataclass: typing.Type[Options.GameOptions] = self.worlds[player].options_dataclass self.worlds[player].o = options_dataclass(**{option_key: getattr(args, option_key)[player] - for option_key in typing.get_type_hints(options_dataclass)}) + for option_key in options_dataclass.type_hints}) def set_item_links(self): item_links = {} diff --git a/test/general/TestFill.py b/test/general/TestFill.py index 5b9b1d9acaa8..d9ac6fcb9f75 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -1,6 +1,4 @@ -import itertools -from argparse import Namespace -from typing import get_type_hints, List, Iterable +from typing import List, Iterable import unittest import Options @@ -25,14 +23,14 @@ def generate_multi_world(players: int = 1) -> MultiWorld: multi_world.player_name[player_id] = "Test Player " + str(player_id) region = Region("Menu", player_id, multi_world, "Menu Region Hint") multi_world.regions.append(region) - for option_key, option in get_type_hints(Options.PerGameCommonOptions).items(): + for option_key, option in Options.PerGameCommonOptions.type_hints.items(): if hasattr(multi_world, option_key): getattr(multi_world, option_key).setdefault(player_id, option.from_any(getattr(option, "default"))) else: setattr(multi_world, option_key, {player_id: option.from_any(getattr(option, "default"))}) # TODO - remove this loop once all worlds use options dataclasses world.o = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id] - for option_key in get_type_hints(world.options_dataclass)}) + for option_key in world.options_dataclass.type_hints}) multi_world.set_seed(0) diff --git a/test/general/__init__.py b/test/general/__init__.py index b6bede376fd7..c53bb23c215f 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -1,20 +1,20 @@ from argparse import Namespace -from typing import get_type_hints +from typing import Type from BaseClasses import MultiWorld, CollectionState -from worlds.AutoWorld import call_all +from worlds.AutoWorld import call_all, World gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"] -def setup_solo_multiworld(world_type) -> MultiWorld: +def setup_solo_multiworld(world_type: Type[World]) -> MultiWorld: multiworld = MultiWorld(1) multiworld.game[1] = world_type.game multiworld.player_name = {1: "Tester"} multiworld.set_seed() multiworld.state = CollectionState(multiworld) args = Namespace() - for name, option in get_type_hints(world_type.options_dataclass).items(): + for name, option in world_type.options_dataclass.type_hints.items(): setattr(args, name, {1: option.from_any(option.default)}) multiworld.set_options(args) for step in gen_steps: diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 4b5c7bc00667..5b4dccf6045d 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -1,5 +1,4 @@ import string -from typing import get_type_hints from .Items import RiskOfRainItem, item_table, item_pool_weights, environment_offest from .Locations import RiskOfRainLocation, get_classic_item_pickups, item_pickups, orderedstage_location From 2584535b004054bc31eb933014287c14998252b7 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Feb 2023 14:52:10 +0100 Subject: [PATCH 035/163] move remaining usages from __annotations__ to metaclass property --- BaseClasses.py | 2 +- Options.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 5e94e1de3490..683417fee0f9 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1586,7 +1586,7 @@ def write_option(option_key: str, option_obj: Union[type(Options.Option), "Optio outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - for f_option, option in self.multiworld.worlds[player].o.__annotations__.items(): + for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): write_option(f_option, option) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) diff --git a/Options.py b/Options.py index 9a48986b35c0..3cd6841fdca9 100644 --- a/Options.py +++ b/Options.py @@ -873,17 +873,17 @@ class CommonOptions(metaclass=OptionsMetaProperty): progression_balancing: ProgressionBalancing accessibility: Accessibility - def as_dict(self, *args: str) -> typing.Dict[str, typing.Any]: + def as_dict(self, *option_names: str) -> typing.Dict[str, typing.Any]: """ Pass the option_names you would like returned as a dictionary as strings. Returns a dictionary of [str, Option.value] """ option_results = {} - for option_name in args: - if option_name in self.__annotations__: + for option_name in option_names: + if option_name in type(self).type_hints: option_results[option_name] = getattr(self, option_name).value else: - raise ValueError(f"{option_name} not found in {self.__annotations__}") + raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") return option_results From 0ae2acc4ca4778f3ebab2d6d4f64537bab4c9cc6 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Feb 2023 13:35:34 +0100 Subject: [PATCH 036/163] move remaining usages from legacy dictionaries to metaclass property --- BaseClasses.py | 6 ------ Generate.py | 9 +++++---- Utils.py | 1 - 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 683417fee0f9..0ca5ca8b32d2 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1,8 +1,6 @@ from __future__ import annotations -import collections import copy -import itertools import functools import json import logging @@ -202,10 +200,6 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu world_type = AutoWorld.AutoWorldRegister.world_types[game] for option_key, option in world_type.options_dataclass.type_hints.items(): getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.per_game_common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) self.worlds[new_id] = world_type(self, new_id) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) diff --git a/Generate.py b/Generate.py index 75f47e79b0c5..a90fe2419c80 100644 --- a/Generate.py +++ b/Generate.py @@ -156,7 +156,8 @@ def main(args=None, callback=ERmain): for yaml in weights_cache[path]: if category_name is None: for category in yaml: - if category in AutoWorldRegister.world_types and key in Options.common_options: + if category in AutoWorldRegister.world_types and \ + key in Options.CommonOptions.type_hints: yaml[category][key] = option elif category_name not in yaml: logging.warning(f"Meta: Category {category_name} is not present in {path}.") @@ -444,8 +445,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b f"which is not enabled.") ret = argparse.Namespace() - for option_key in Options.per_game_common_options: - if option_key in weights and option_key not in Options.common_options: + for option_key in Options.PerGameCommonOptions.type_hints: + if option_key in weights and option_key not in Options.CommonOptions.type_hints: raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") ret.game = get_choice("game", weights) @@ -460,7 +461,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b game_weights = weights[ret.game] ret.name = get_choice('name', weights) - for option_key, option in Options.common_options.items(): + for option_key, option in Options.CommonOptions.type_hints.items(): setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) if ret.game in AutoWorldRegister.world_types: diff --git a/Utils.py b/Utils.py index 777c3283935c..133f1c452e06 100644 --- a/Utils.py +++ b/Utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import types import typing import builtins import os From a147aaee869984b5ad2f4a73064cc90fa2e86539 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Feb 2023 13:45:53 +0100 Subject: [PATCH 037/163] remove legacy dictionaries --- Options.py | 8 -------- worlds/AutoWorld.py | 3 +-- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Options.py b/Options.py index 3cd6841fdca9..13c1a7a4b1e7 100644 --- a/Options.py +++ b/Options.py @@ -887,10 +887,6 @@ def as_dict(self, *option_names: str) -> typing.Dict[str, typing.Any]: return option_results -common_options = typing.get_type_hints(CommonOptions) -# TODO - remove this dict once all worlds use options dataclasses - - class ItemSet(OptionSet): verify_item_name = True convert_name_groups = True @@ -1016,10 +1012,6 @@ class PerGameCommonOptions(CommonOptions): item_links: ItemLinks -per_game_common_options = typing.get_type_hints(PerGameCommonOptions) -# TODO - remove this dict once all worlds use options dataclasses - - GameOptions = typing.TypeVar("GameOptions", bound=PerGameCommonOptions) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 2a1792014aba..10143edde0a3 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -7,7 +7,7 @@ from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING, \ ClassVar -from Options import AssembleOptions, GameOptions, PerGameCommonOptions +from Options import GameOptions, PerGameCommonOptions from BaseClasses import CollectionState if TYPE_CHECKING: @@ -138,7 +138,6 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - option_definitions: ClassVar[Dict[str, AssembleOptions]] = {} # TODO - remove this once all worlds use options dataclasses options_dataclass: Type[GameOptions] = PerGameCommonOptions # link your Options mapping o: PerGameCommonOptions From 88db4f70f5bc5803934e2b4214b7e5490e0d3f12 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Feb 2023 14:24:45 +0100 Subject: [PATCH 038/163] cache the metaclass property --- Options.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Options.py b/Options.py index 13c1a7a4b1e7..5c460b16a2e9 100644 --- a/Options.py +++ b/Options.py @@ -2,6 +2,7 @@ import abc from copy import deepcopy from dataclasses import dataclass +import functools import math import numbers import typing @@ -863,6 +864,7 @@ class ProgressionBalancing(SpecialRange): class OptionsMetaProperty(type): @property + @functools.lru_cache(maxsize=None) def type_hints(cls) -> typing.Dict[str, AssembleOptions]: """Returns type hints of the class as a dictionary.""" return typing.get_type_hints(cls) From 01374e0dab303ded5b8bf057d93299fb19de0329 Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Sat, 25 Feb 2023 13:20:02 +0100 Subject: [PATCH 039/163] clarify inheritance in world api --- docs/world api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/world api.md b/docs/world api.md index c7e3691b6531..11fb167df9a2 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -329,6 +329,7 @@ class FixXYZGlitch(Toggle): display_name = "Fix XYZ Glitch" # By convention, we call the options dataclass `Options`. +# It has to be derived from 'PerGameCommonOptions'. @dataclass class MyGameOptions(PerGameCommonOptions): difficulty: Difficulty From ac123dbb9d1ce838d5afd0b6b89a77c6c7c39540 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 13 Mar 2023 18:07:41 -0500 Subject: [PATCH 040/163] move the messenger to new options system --- worlds/messenger/Options.py | 26 ++++++++++++++------------ worlds/messenger/Rules.py | 15 +++++++++------ worlds/messenger/SubClasses.py | 9 ++++++--- worlds/messenger/__init__.py | 25 +++++++++++++------------ 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py index 1baca12e3ab9..600f6c202fdf 100644 --- a/worlds/messenger/Options.py +++ b/worlds/messenger/Options.py @@ -1,4 +1,6 @@ -from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice +from dataclasses import dataclass + +from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice, PerGameCommonOptions class MessengerAccessibility(Accessibility): @@ -53,14 +55,14 @@ class RequiredSeals(Range): default = range_end -messenger_options = { - "accessibility": MessengerAccessibility, - "enable_logic": Logic, - "shuffle_seals": PowerSeals, - "goal": Goal, - "music_box": MusicBox, - "notes_needed": NotesNeeded, - "total_seals": AmountSeals, - "percent_seals_required": RequiredSeals, - "death_link": DeathLink, -} +@dataclass +class MessengerOptions(PerGameCommonOptions): + accessibility: MessengerAccessibility + enable_logic: Logic + shuffle_seals: PowerSeals + goal: Goal + music_box: MusicBox + notes_needed: NotesNeeded + total_seals: AmountSeals + percent_seals_required: RequiredSeals + death_link: DeathLink diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index c2731678025a..ee7710942e00 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -88,11 +88,12 @@ def has_vertical(self, state: CollectionState) -> bool: return self.has_wingsuit(state) or self.has_dart(state) def has_enough_seals(self, state: CollectionState) -> bool: - required_seals = state.multiworld.worlds[self.player].required_seals + required_seals = self.world.required_seals return state.has("Power Seal", self.player, required_seals) def set_messenger_rules(self) -> None: multiworld = self.world.multiworld + options = self.world.o for region in multiworld.get_regions(self.player): if region.name in self.region_rules: @@ -101,16 +102,16 @@ def set_messenger_rules(self) -> None: for loc in region.locations: if loc.name in self.location_rules: loc.access_rule = self.location_rules[loc.name] - if multiworld.goal[self.player] == Goal.option_power_seal_hunt: + if options.goal == Goal.option_power_seal_hunt: set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), lambda state: state.has("Shop Chest", self.player)) - if multiworld.enable_logic[self.player]: + if options.enable_logic: multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) else: multiworld.accessibility[self.player].value = MessengerAccessibility.option_minimal if multiworld.accessibility[self.player] > MessengerAccessibility.option_locations: - set_self_locking_items(multiworld, self.player) + set_self_locking_items(self.world, self.player) def location_item_name(state: CollectionState, location_name: str, player: int) -> Optional[Tuple[str, int]]: @@ -146,13 +147,15 @@ def add_allowed_rules(area: Union[Location, Entrance], location: Location) -> No add_allowed_rules(spot, spot) -def set_self_locking_items(multiworld: MultiWorld, player: int) -> None: +def set_self_locking_items(world: MessengerWorld, player: int) -> None: + multiworld = world.multiworld + # do the ones for seal shuffle on and off first allow_self_locking_items(multiworld.get_location("Key of Strength", player), "Power Thistle") allow_self_locking_items(multiworld.get_location("Key of Love", player), "Sun Crest", "Moon Crest") allow_self_locking_items(multiworld.get_location("Key of Courage", player), "Demon King Crown") # add these locations when seals aren't shuffled - if not multiworld.shuffle_seals[player]: + if not world.o.shuffle_seals: allow_self_locking_items(multiworld.get_region("Cloud Ruins", player), "Ruxxtin's Amulet") allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index 32803f5e0d1b..d994e076d9f1 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -12,18 +12,21 @@ class MessengerRegion(Region): + world: MessengerWorld + def __init__(self, name: str, world: MessengerWorld): super().__init__(name, world.player, world.multiworld) - self.add_locations(self.multiworld.worlds[self.player].location_name_to_id) + self.world = world + self.add_locations(world.location_name_to_id) world.multiworld.regions.append(self) def add_locations(self, name_to_id: Dict[str, int]) -> None: for loc in REGIONS[self.name]: self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None))) - if self.name == "The Shop" and self.multiworld.goal[self.player] > Goal.option_open_music_box: + if self.name == "The Shop" and self.world.o.goal > Goal.option_open_music_box: self.locations.append(MessengerLocation("Shop Chest", self, name_to_id.get("Shop Chest", None))) # putting some dumb special case for searing crags and ToT so i can split them into 2 regions - if self.multiworld.shuffle_seals[self.player] and self.name not in {"Searing Crags", "Tower HQ"}: + if self.world.o.shuffle_seals and self.name not in {"Searing Crags", "Tower HQ"}: for seal_loc in SEALS: if seal_loc.startswith(self.name.split(" ")[0]): self.locations.append(MessengerLocation(seal_loc, self, name_to_id.get(seal_loc, None))) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 1c42b30494a9..ccb00a445974 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -3,7 +3,7 @@ from BaseClasses import Tutorial, ItemClassification from worlds.AutoWorld import World, WebWorld from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS, ALWAYS_LOCATIONS, SEALS, ALL_ITEMS -from .Options import messenger_options, NotesNeeded, Goal, PowerSeals +from .Options import MessengerOptions, NotesNeeded, Goal, PowerSeals from .Regions import REGIONS, REGION_CONNECTIONS from .Rules import MessengerRules from .SubClasses import MessengerRegion, MessengerItem @@ -43,7 +43,8 @@ class MessengerWorld(World): "Shuriken": {"Windmill Shuriken"}, } - option_definitions = messenger_options + options_dataclass = MessengerOptions + o: MessengerOptions base_offset = 0xADD_000 item_name_to_id = {item: item_id @@ -59,10 +60,10 @@ class MessengerWorld(World): required_seals: Optional[int] = None def generate_early(self) -> None: - if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: - self.multiworld.shuffle_seals[self.player].value = PowerSeals.option_true - self.total_seals = self.multiworld.total_seals[self.player].value - self.required_seals = int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals) + if self.o.goal == Goal.option_power_seal_hunt: + self.o.shuffle_seals.value = PowerSeals.option_true + self.total_seals = self.o.total_seals.value + self.required_seals = int(self.o.percent_seals_required.value / 100 * self.total_seals) def create_regions(self) -> None: for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: @@ -71,14 +72,14 @@ def create_regions(self) -> None: def create_items(self) -> None: itempool: List[MessengerItem] = [] - if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + if self.o.goal == Goal.option_power_seal_hunt: seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] for i in range(self.required_seals): seals[i].classification = ItemClassification.progression_skip_balancing itempool += seals else: notes = self.multiworld.random.sample(NOTES, k=len(NOTES)) - precollected_notes_amount = NotesNeeded.range_end - self.multiworld.notes_needed[self.player] + precollected_notes_amount = NotesNeeded.range_end - self.o.notes_needed if precollected_notes_amount: for note in notes[:precollected_notes_amount]: self.multiworld.push_precollected(self.create_item(note)) @@ -109,12 +110,12 @@ def fill_slot_data(self) -> Dict[str, Any]: locations[loc.address] = [loc.item.name, self.multiworld.player_name[loc.item.player]] return { - "deathlink": self.multiworld.death_link[self.player].value, - "goal": self.multiworld.goal[self.player].current_key, - "music_box": self.multiworld.music_box[self.player].value, + "deathlink": self.o.death_link.value, + "goal": self.o.goal.current_key, + "music_box": self.o.music_box.value, "required_seals": self.required_seals, "locations": locations, - "settings": {"Difficulty": "Basic" if not self.multiworld.shuffle_seals[self.player] else "Advanced"} + "settings": {"Difficulty": "Basic" if not self.o.shuffle_seals else "Advanced"} } def get_filler_item_name(self) -> str: From e6806ed8f53973b28ce7030122163519ecf8e8e5 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 13 Mar 2023 18:40:11 -0500 Subject: [PATCH 041/163] add an assert for my dumb --- Options.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Options.py b/Options.py index babaace6fcfb..b4ec54d19df7 100644 --- a/Options.py +++ b/Options.py @@ -878,6 +878,12 @@ class ProgressionBalancing(SpecialRange): class OptionsMetaProperty(type): + def __new__(mcs, name, bases, attrs): + for attr, attr_type in attrs.items(): + assert not isinstance(attr_type, AssembleOptions),\ + f"Options for {name} should be type hinted on the class, not assigned" + return super().__new__(mcs, name, bases, attrs) + @property @functools.lru_cache(maxsize=None) def type_hints(cls) -> typing.Dict[str, AssembleOptions]: From 2c8afbc5f45088adb8eea545125298d265e02198 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Mar 2023 14:19:57 -0500 Subject: [PATCH 042/163] update the doc --- docs/options api.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/options api.md b/docs/options api.md index a1407f2cebc0..effd8578d57b 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -27,14 +27,16 @@ options: ```python # Options.py +from Options import Toggle, PerGameCommonOptions + + class StartingSword(Toggle): """Adds a sword to your starting inventory.""" display_name = "Start With Sword" -example_options = { - "starting_sword": StartingSword -} +class ExampleGameOptions(PerGameCommonOptions): + starting_sword: StartingSword ``` This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it @@ -42,27 +44,30 @@ to our world's `__init__.py`: ```python from worlds.AutoWorld import World -from .Options import options +from .Options import ExampleGameOptions class ExampleWorld(World): - option_definitions = options + # this gives the generator all the definitions for our options + options_dataclass = ExampleGameOptions + # this gives us typing hints for all the options we defined + options: ExampleGameOptions ``` ### Option Checking Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after world instantiation. These are created as attributes on the MultiWorld and can be accessed with -`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to +`self.o.my_option_name`. This is the option class, which supports direct comparison methods to relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is the option class's `value` attribute. For our example above we can do a simple check: ```python -if self.multiworld.starting_sword[self.player]: +if self.options.starting_sword: do_some_things() ``` or if I need a boolean object, such as in my slot_data I can access it as: ```python -start_with_sword = bool(self.multiworld.starting_sword[self.player].value) +start_with_sword = bool(self.options.starting_sword.value) ``` ## Generic Option Classes @@ -114,7 +119,7 @@ Like Toggle, but 1 (true) is the default value. A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with: ```python -if self.multiworld.sword_availability[self.player] == "early_sword": +if self.options.sword_availability == "early_sword": do_early_sword_things() ``` @@ -122,7 +127,7 @@ or: ```python from .Options import SwordAvailability -if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword: +if self.options.sword_availability == SwordAvailability.option_early_sword: do_early_sword_things() ``` @@ -154,7 +159,7 @@ within the world. Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any user defined string as a valid option, so will either need to be validated by adding a validation step to the option class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified -point, `self.multiworld.my_option[self.player].current_key` will always return a string. +point, `self.options.my_option.current_key` will always return a string. ### PlandoBosses An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports From f8ba777c0003a7a6826dd86507ac3898535346ae Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Mar 2023 14:26:19 -0500 Subject: [PATCH 043/163] rename o to options --- BaseClasses.py | 8 +-- Fill.py | 10 ++-- Main.py | 22 ++++----- docs/world api.md | 21 +++++--- test/general/TestFill.py | 6 +-- worlds/AutoWorld.py | 2 +- worlds/factorio/__init__.py | 6 +-- worlds/messenger/Rules.py | 2 +- worlds/messenger/SubClasses.py | 4 +- worlds/messenger/__init__.py | 22 ++++----- worlds/ror2/__init__.py | 90 +++++++++++++++++----------------- 11 files changed, 99 insertions(+), 94 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index a0a72af637bc..020bd0dfd694 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -237,14 +237,14 @@ def set_options(self, args: Namespace) -> None: setattr(self, option_key, option_values) # TODO - remove this loop once all worlds use options dataclasses options_dataclass: typing.Type[Options.GameOptions] = self.worlds[player].options_dataclass - self.worlds[player].o = options_dataclass(**{option_key: getattr(args, option_key)[player] - for option_key in options_dataclass.type_hints}) + self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] + for option_key in options_dataclass.type_hints}) def set_item_links(self): item_links = {} replacement_prio = [False, True, None] for player in self.player_ids: - for item_link in self.worlds[player].o.item_links.value: + for item_link in self.worlds[player].options.item_links.value: if item_link["name"] in item_links: if item_links[item_link["name"]]["game"] != self.game[player]: raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}") @@ -1250,7 +1250,7 @@ def get_path(state, region): def to_file(self, filename: str): def write_option(option_key: str, option_obj: type(Options.Option)): - res = getattr(self.multiworld.worlds[player].o, option_key) + res = getattr(self.multiworld.worlds[player].options, option_key) display_name = getattr(option_obj, "display_name", option_key) try: outfile.write(f'{display_name + ":":33}{res.current_option_name}\n') diff --git a/Fill.py b/Fill.py index f03a14d72390..2c5b71b653f6 100644 --- a/Fill.py +++ b/Fill.py @@ -56,7 +56,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.worlds[item_to_place.player].o.accessibility == Accessibility.option_minimal: + if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game @@ -221,7 +221,7 @@ def fast_fill(world: MultiWorld, def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.worlds[player].o.accessibility == "minimal"} + minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: @@ -244,7 +244,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and world.worlds[item.player].o.accessibility != 'minimal') + return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) @@ -487,9 +487,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None: # If other players are below the threshold value, swap progression in this sphere into earlier spheres, # which gives more locations available by this sphere. balanceable_players: typing.Dict[int, float] = { - player: world.worlds[player].o.progression_balancing / 100 + player: world.worlds[player].options.progression_balancing / 100 for player in world.player_ids - if world.worlds[player].o.progression_balancing > 0 + if world.worlds[player].options.progression_balancing > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') diff --git a/Main.py b/Main.py index dd2905db6e7b..742e4ac53ccf 100644 --- a/Main.py +++ b/Main.py @@ -112,7 +112,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info('') for player in world.player_ids: - for item_name, count in world.worlds[player].o.start_inventory.value.items(): + for item_name, count in world.worlds[player].options.start_inventory.value.items(): for _ in range(count): world.push_precollected(world.create_item(item_name, player)) @@ -130,21 +130,21 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player in world.player_ids: # items can't be both local and non-local, prefer local - world.worlds[player].o.non_local_items.value -= world.worlds[player].o.local_items.value - world.worlds[player].o.non_local_items.value -= set(world.local_early_items[player]) + world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value + world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) if world.players > 1: locality_rules(world) else: - world.worlds[1].o.non_local_items.value = set() - world.worlds[1].o.local_items.value = set() + world.worlds[1].options.non_local_items.value = set() + world.worlds[1].options.local_items.value = set() AutoWorld.call_all(world, "set_rules") for player in world.player_ids: - exclusion_rules(world, player, world.worlds[player].o.exclude_locations.value) - world.worlds[player].o.priority_locations.value -= world.worlds[player].o.exclude_locations.value - for location_name in world.worlds[player].o.priority_locations.value: + exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) + world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value + for location_name in world.worlds[player].options.priority_locations.value: world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY AutoWorld.call_all(world, "generate_basic") @@ -347,11 +347,11 @@ def precollect_hint(location): f" {location}" locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags - if location.name in world.worlds[location.player].o.start_location_hints: + if location.name in world.worlds[location.player].options.start_location_hints: precollect_hint(location) - elif location.item.name in world.worlds[location.item.player].o.start_hints: + elif location.item.name in world.worlds[location.item.player].options.start_hints: precollect_hint(location) - elif any([location.item.name in world.worlds[player].o.start_hints + elif any([location.item.name in world.worlds[player].options.start_hints for player in world.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) diff --git a/docs/world api.md b/docs/world api.md index 6a5a5c9a764a..ff769ad93500 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -336,17 +336,19 @@ class MyGameOptions(PerGameCommonOptions): final_boss_hp: FinalBossHP fix_xyz_glitch: FixXYZGlitch ``` + ```python # __init__.py from worlds.AutoWorld import World from .Options import MyGameOptions # import the options dataclass + class MyGameWorld(World): - #... + # ... options_dataclass = MyGameOptions # assign the options dataclass to the world - o: MyGameOptions # typing for option results - #... + options: MyGameOptions # typing for option results + # ... ``` ### A World Class Skeleton @@ -361,17 +363,20 @@ from worlds.AutoWorld import World from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification from Utils import get_options, output_path + class MyGameItem(Item): # or from Items import MyGameItem game = "My Game" # name of the game/world this item is from + class MyGameLocation(Location): # or from Locations import MyGameLocation game = "My Game" # name of the game/world this location is in + class MyGameWorld(World): """Insert description of the world/game here.""" game: str = "My Game" # name of the game/world options_dataclass = MyGameOptions # options the player can set - o: MyGameOptions # typing for option results + options: MyGameOptions # typing for option results topology_present: bool = True # show path to required location checks in spoiler # data_version is used to signal that items, locations or their names @@ -451,7 +456,7 @@ In addition, the following methods can be implemented and are called in this ord ```python def generate_early(self) -> None: # read player settings to world instance - self.final_boss_hp = self.o.final_boss_hp.value + self.final_boss_hp = self.options.final_boss_hp.value ``` #### create_item @@ -666,7 +671,7 @@ def generate_output(self, output_directory: str): "seed": self.multiworld.seed_name, # to verify the server's multiworld "slot": self.multiworld.player_name[self.player], # to connect to server "items": {location.name: location.item.name - if location.item.player == self.player else "Remote" + if location.item.player == self.player else "Remote" for location in self.multiworld.get_filled_locations(self.player)}, # store start_inventory from player's .yaml # make sure to mark as not remote_start_inventory when connecting if stored in rom/mod @@ -674,9 +679,9 @@ def generate_output(self, output_directory: str): in self.multiworld.precollected_items[self.player]], "final_boss_hp": self.final_boss_hp, # store option name "easy", "normal" or "hard" for difficuly - "difficulty": self.o.difficulty.current_key, + "difficulty": self.options.difficulty.current_key, # store option value True or False for fixing a glitch - "fix_xyz_glitch": self.o.fix_xyz_glitch.value + "fix_xyz_glitch": self.options.fix_xyz_glitch.value } # point to a ROM specified by the installation src = Utils.get_options()["mygame_options"]["rom_file"] diff --git a/test/general/TestFill.py b/test/general/TestFill.py index d9ac6fcb9f75..6055fd382ee9 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -29,8 +29,8 @@ def generate_multi_world(players: int = 1) -> MultiWorld: else: setattr(multi_world, option_key, {player_id: option.from_any(getattr(option, "default"))}) # TODO - remove this loop once all worlds use options dataclasses - world.o = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id] - for option_key in world.options_dataclass.type_hints}) + world.options = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id] + for option_key in world.options_dataclass.type_hints}) multi_world.set_seed(0) @@ -197,7 +197,7 @@ def test_minimal_fill(self): items = player1.prog_items locations = player1.locations - multi_world.worlds[player1.id].o.accessibility = Accessibility.from_any(Accessibility.option_minimal) + multi_world.worlds[player1.id].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) multi_world.completion_condition[player1.id] = lambda state: state.has( items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index e9c670329601..36e9df1920ce 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -140,7 +140,7 @@ class World(metaclass=AutoWorldRegister): options_dataclass: Type[GameOptions] = PerGameCommonOptions """link your Options mapping""" - o: PerGameCommonOptions + options: PerGameCommonOptions """resulting options for the player of this world""" game: ClassVar[str] diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index a25e084819b8..ab738d6b89c9 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -73,9 +73,9 @@ def __init__(self, world, player: int): generate_output = generate_mod def generate_early(self) -> None: - self.o.max_tech_cost = max(self.o.max_tech_cost, self.o.min_tech_cost) - self.tech_mix = self.o.tech_cost_mix - self.skip_silo = self.o.silo.value == Silo.option_spawn + self.options.max_tech_cost = max(self.options.max_tech_cost, self.options.min_tech_cost) + self.tech_mix = self.options.tech_cost_mix + self.skip_silo = self.options.silo.value == Silo.option_spawn def create_regions(self): player = self.player diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index 625e352c6d41..d15a7d7100fe 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -93,7 +93,7 @@ def has_enough_seals(self, state: CollectionState) -> bool: def set_messenger_rules(self) -> None: multiworld = self.world.multiworld - options = self.world.o + options = self.world.options for region in multiworld.get_regions(self.player): if region.name in self.region_rules: diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index d994e076d9f1..936ee4054cec 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -23,10 +23,10 @@ def __init__(self, name: str, world: MessengerWorld): def add_locations(self, name_to_id: Dict[str, int]) -> None: for loc in REGIONS[self.name]: self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None))) - if self.name == "The Shop" and self.world.o.goal > Goal.option_open_music_box: + if self.name == "The Shop" and self.world.options.goal > Goal.option_open_music_box: self.locations.append(MessengerLocation("Shop Chest", self, name_to_id.get("Shop Chest", None))) # putting some dumb special case for searing crags and ToT so i can split them into 2 regions - if self.world.o.shuffle_seals and self.name not in {"Searing Crags", "Tower HQ"}: + if self.world.options.shuffle_seals and self.name not in {"Searing Crags", "Tower HQ"}: for seal_loc in SEALS: if seal_loc.startswith(self.name.split(" ")[0]): self.locations.append(MessengerLocation(seal_loc, self, name_to_id.get(seal_loc, None))) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index ccb00a445974..76245bfebd20 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -44,7 +44,7 @@ class MessengerWorld(World): } options_dataclass = MessengerOptions - o: MessengerOptions + options: MessengerOptions base_offset = 0xADD_000 item_name_to_id = {item: item_id @@ -60,10 +60,10 @@ class MessengerWorld(World): required_seals: Optional[int] = None def generate_early(self) -> None: - if self.o.goal == Goal.option_power_seal_hunt: - self.o.shuffle_seals.value = PowerSeals.option_true - self.total_seals = self.o.total_seals.value - self.required_seals = int(self.o.percent_seals_required.value / 100 * self.total_seals) + if self.options.goal == Goal.option_power_seal_hunt: + self.options.shuffle_seals.value = PowerSeals.option_true + self.total_seals = self.options.total_seals.value + self.required_seals = int(self.options.percent_seals_required.value / 100 * self.total_seals) def create_regions(self) -> None: for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: @@ -72,14 +72,14 @@ def create_regions(self) -> None: def create_items(self) -> None: itempool: List[MessengerItem] = [] - if self.o.goal == Goal.option_power_seal_hunt: + if self.options.goal == Goal.option_power_seal_hunt: seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] for i in range(self.required_seals): seals[i].classification = ItemClassification.progression_skip_balancing itempool += seals else: notes = self.multiworld.random.sample(NOTES, k=len(NOTES)) - precollected_notes_amount = NotesNeeded.range_end - self.o.notes_needed + precollected_notes_amount = NotesNeeded.range_end - self.options.notes_needed if precollected_notes_amount: for note in notes[:precollected_notes_amount]: self.multiworld.push_precollected(self.create_item(note)) @@ -110,12 +110,12 @@ def fill_slot_data(self) -> Dict[str, Any]: locations[loc.address] = [loc.item.name, self.multiworld.player_name[loc.item.player]] return { - "deathlink": self.o.death_link.value, - "goal": self.o.goal.current_key, - "music_box": self.o.music_box.value, + "deathlink": self.options.death_link.value, + "goal": self.options.goal.current_key, + "music_box": self.options.music_box.value, "required_seals": self.required_seals, "locations": locations, - "settings": {"Difficulty": "Basic" if not self.o.shuffle_seals else "Advanced"} + "settings": {"Difficulty": "Basic" if not self.options.shuffle_seals else "Advanced"} } def get_filler_item_name(self) -> str: diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index e91b897d1e9c..544fb91dad2e 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -30,7 +30,7 @@ class RiskOfRainWorld(World): """ game = "Risk of Rain 2" options_dataclass = ROR2Options - o: ROR2Options + options: ROR2Options topology_present = False item_name_to_id = item_table @@ -43,44 +43,44 @@ class RiskOfRainWorld(World): def generate_early(self) -> None: # figure out how many revivals should exist in the pool - if self.o.goal == "classic": - total_locations = self.o.total_locations.value + if self.options.goal == "classic": + total_locations = self.options.total_locations.value else: total_locations = len( orderedstage_location.get_locations( - chests=self.o.chests_per_stage.value, - shrines=self.o.shrines_per_stage.value, - scavengers=self.o.scavengers_per_stage.value, - scanners=self.o.scanner_per_stage.value, - altars=self.o.altars_per_stage.value, - dlc_sotv=self.o.dlc_sotv.value + chests=self.options.chests_per_stage.value, + shrines=self.options.shrines_per_stage.value, + scavengers=self.options.scavengers_per_stage.value, + scanners=self.options.scanner_per_stage.value, + altars=self.options.altars_per_stage.value, + dlc_sotv=self.options.dlc_sotv.value ) ) - self.total_revivals = int(self.o.total_revivals.value / 100 * + self.total_revivals = int(self.options.total_revivals.value / 100 * total_locations) - if self.o.start_with_revive: + if self.options.start_with_revive: self.total_revivals -= 1 def create_items(self) -> None: # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend - if self.o.start_with_revive: + if self.options.start_with_revive: self.multiworld.push_precollected(self.multiworld.create_item("Dio's Best Friend", self.player)) environments_pool = {} # only mess with the environments if they are set as items - if self.o.goal == "explore": + if self.options.goal == "explore": # figure out all available ordered stages for each tier environment_available_orderedstages_table = environment_vanilla_orderedstages_table - if self.o.dlc_sotv: + if self.options.dlc_sotv: environment_available_orderedstages_table = collapse_dict_list_vertical(environment_available_orderedstages_table, environment_sotv_orderedstages_table) environments_pool = shift_by_offset(environment_vanilla_table, environment_offest) - if self.o.dlc_sotv: + if self.options.dlc_sotv: environment_offset_table = shift_by_offset(environment_sotv_table, environment_offest) environments_pool = {**environments_pool, **environment_offset_table} - environments_to_precollect = 5 if self.o.begin_with_loop else 1 + environments_to_precollect = 5 if self.options.begin_with_loop else 1 # percollect environments for each stage (or just stage 1) for i in range(environments_to_precollect): unlock = self.multiworld.random.choices(list(environment_available_orderedstages_table[i].keys()), k=1) @@ -88,9 +88,9 @@ def create_items(self) -> None: environments_pool.pop(unlock[0]) # if presets are enabled generate junk_pool from the selected preset - pool_option = self.o.item_weights.value + pool_option = self.options.item_weights.value junk_pool: Dict[str, int] = {} - if self.o.item_pool_presets: + if self.options.item_pool_presets: # generate chaos weights if the preset is chosen if pool_option == ItemWeights.option_chaos: for name, max_value in item_pool_weights[pool_option].items(): @@ -99,24 +99,24 @@ def create_items(self) -> None: junk_pool = item_pool_weights[pool_option].copy() else: # generate junk pool from user created presets junk_pool = { - "Item Scrap, Green": self.o.green_scrap.value, - "Item Scrap, Red": self.o.red_scrap.value, - "Item Scrap, Yellow": self.o.yellow_scrap.value, - "Item Scrap, White": self.o.white_scrap.value, - "Common Item": self.o.common_item.value, - "Uncommon Item": self.o.uncommon_item.value, - "Legendary Item": self.o.legendary_item.value, - "Boss Item": self.o.boss_item.value, - "Lunar Item": self.o.lunar_item.value, - "Void Item": self.o.void_item.value, - "Equipment": self.o.equipment.value + "Item Scrap, Green": self.options.green_scrap.value, + "Item Scrap, Red": self.options.red_scrap.value, + "Item Scrap, Yellow": self.options.yellow_scrap.value, + "Item Scrap, White": self.options.white_scrap.value, + "Common Item": self.options.common_item.value, + "Uncommon Item": self.options.uncommon_item.value, + "Legendary Item": self.options.legendary_item.value, + "Boss Item": self.options.boss_item.value, + "Lunar Item": self.options.lunar_item.value, + "Void Item": self.options.void_item.value, + "Equipment": self.options.equipment.value } # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled - if not self.o.enable_lunar or pool_option == ItemWeights.option_lunartic: + if not self.options.enable_lunar or pool_option == ItemWeights.option_lunartic: junk_pool.pop("Lunar Item") # remove void items from the pool - if not self.o.dlc_sotv or pool_option == ItemWeights.option_void: + if not self.options.dlc_sotv or pool_option == ItemWeights.option_void: junk_pool.pop("Void Item") # Generate item pool @@ -129,19 +129,19 @@ def create_items(self) -> None: # precollected environments are popped from the pool so counting like this is valid nonjunk_item_count = self.total_revivals + len(environments_pool) - if self.o.goal == "classic": + if self.options.goal == "classic": # classic mode - total_locations = self.o.total_locations.value + total_locations = self.options.total_locations.value else: # explore mode total_locations = len( orderedstage_location.get_locations( - chests=self.o.chests_per_stage.value, - shrines=self.o.shrines_per_stage.value, - scavengers=self.o.scavengers_per_stage.value, - scanners=self.o.scanner_per_stage.value, - altars=self.o.altars_per_stage.value, - dlc_sotv=self.o.dlc_sotv.value + chests=self.options.chests_per_stage.value, + shrines=self.options.shrines_per_stage.value, + scavengers=self.options.scavengers_per_stage.value, + scanners=self.options.scanner_per_stage.value, + altars=self.options.altars_per_stage.value, + dlc_sotv=self.options.dlc_sotv.value ) ) junk_item_count = total_locations - nonjunk_item_count @@ -158,7 +158,7 @@ def set_rules(self) -> None: def create_regions(self) -> None: - if self.o.goal == "classic": + if self.options.goal == "classic": # classic mode menu = create_region(self.multiworld, self.player, "Menu") self.multiworld.regions.append(menu) @@ -167,7 +167,7 @@ def create_regions(self) -> None: victory_region = create_region(self.multiworld, self.player, "Victory") self.multiworld.regions.append(victory_region) petrichor = create_region(self.multiworld, self.player, "Petrichor V", - get_classic_item_pickups(self.o.total_locations.value)) + get_classic_item_pickups(self.options.total_locations.value)) self.multiworld.regions.append(petrichor) # classic mode can get to victory from the beginning of the game @@ -185,7 +185,7 @@ def create_regions(self) -> None: create_events(self.multiworld, self.player) def fill_slot_data(self): - options_dict = self.o.as_dict("item_pickup_step", "shrine_use_step", "goal", "total_locations", + options_dict = self.options.as_dict("item_pickup_step", "shrine_use_step", "goal", "total_locations", "chests_per_stage", "shrines_per_stage", "scavengers_per_stage", "scanner_per_stage", "altars_per_stage", "total_revivals", "start_with_revive", "final_stage_death", "death_link") @@ -225,12 +225,12 @@ def create_item(self, name: str) -> Item: def create_events(world: MultiWorld, player: int) -> None: - total_locations = world.total_locations[player].value + total_locations = world.worlds[player].options.total_locations.value num_of_events = total_locations // 25 if total_locations / 25 == num_of_events: num_of_events -= 1 world_region = world.get_region("Petrichor V", player) - if world.goal[player] == "classic": + if world.worlds[player].options.goal == "classic": # only setup Pickups when using classic_mode for i in range(num_of_events): event_loc = RiskOfRainLocation(player, f"Pickup{(i + 1) * 25}", None, world_region) @@ -238,7 +238,7 @@ def create_events(world: MultiWorld, player: int) -> None: event_loc.access_rule = \ lambda state, i=i: state.can_reach(f"ItemPickup{((i + 1) * 25) - 1}", "Location", player) world_region.locations.append(event_loc) - elif world.goal[player] == "explore": + elif world.worlds[player].options.goal == "explore": for n in range(1, 6): event_region = world.get_region(f"OrderedStage_{n}", player) From 984d6594376b235e4794e0571ebed7738bf59803 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 14 Mar 2023 14:43:12 -0500 Subject: [PATCH 044/163] missed a spot --- worlds/messenger/Rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index d15a7d7100fe..a47036bfd79a 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -123,6 +123,6 @@ def set_self_locking_items(world: MessengerWorld, player: int) -> None: allow_self_locking_items(multiworld.get_location("Key of Courage", player), "Demon King Crown") # add these locations when seals aren't shuffled - if not world.o.shuffle_seals: + if not world.options.shuffle_seals: allow_self_locking_items(multiworld.get_region("Cloud Ruins", player), "Ruxxtin's Amulet") allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) From e959c49600dffbe2c213b0cbfa951b0c0de9d32b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 1 Apr 2023 13:08:29 -0500 Subject: [PATCH 045/163] update new messenger options --- worlds/messenger/Rules.py | 7 +++---- worlds/messenger/__init__.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index 8b0e8d4a3756..af31b5791e9a 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -98,7 +98,6 @@ def true(self, state: CollectionState) -> bool: def set_messenger_rules(self) -> None: multiworld = self.world.multiworld - options = self.world.options for region in multiworld.get_regions(self.player): if region.name in self.region_rules: @@ -107,7 +106,7 @@ def set_messenger_rules(self) -> None: for loc in region.locations: if loc.name in self.location_rules: loc.access_rule = self.location_rules[loc.name] - if options.goal == Goal.option_power_seal_hunt: + if self.world.options.goal == Goal.option_power_seal_hunt: set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), lambda state: state.has("Shop Chest", self.player)) @@ -154,7 +153,7 @@ def has_windmill(self, state: CollectionState) -> bool: def set_messenger_rules(self) -> None: super().set_messenger_rules() for loc, rule in self.extra_rules.items(): - if not self.world.multiworld.shuffle_seals[self.player] and "Seal" in loc: + if not self.world.options.shuffle_seals and "Seal" in loc: continue add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or") @@ -210,7 +209,7 @@ def __init__(self, world: MessengerWorld) -> None: def set_messenger_rules(self) -> None: super().set_messenger_rules() self.world.multiworld.completion_condition[self.player] = lambda state: True - self.world.multiworld.accessibility[self.player].value = MessengerAccessibility.option_minimal + self.world.options.accessibility.value = MessengerAccessibility.option_minimal def set_self_locking_items(world: MessengerWorld, player: int) -> None: diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index e9987dfb3eb9..e7bafbf7842e 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -101,7 +101,7 @@ def create_items(self) -> None: self.multiworld.itempool += itempool def set_rules(self) -> None: - logic = self.multiworld.logic_level[self.player] + logic = self.options.logic_level if logic == Logic.option_normal: Rules.MessengerRules(self).set_messenger_rules() elif logic == Logic.option_hard: @@ -133,5 +133,5 @@ def get_filler_item_name(self) -> str: def create_item(self, name: str) -> MessengerItem: item_id: Optional[int] = self.item_name_to_id.get(name, None) override_prog = name in {"Windmill Shuriken"} and getattr(self, "multiworld") is not None \ - and self.multiworld.logic_level[self.player] > Logic.option_normal + and self.options.logic_level > Logic.option_normal return MessengerItem(name, self.player, item_id, override_prog) From ec8c75b41a8dea117b5e7e33ca8ec46a130e5aaf Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 1 Apr 2023 18:23:22 -0500 Subject: [PATCH 046/163] comment spacing Co-authored-by: Doug Hoskisson --- docs/world api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/world api.md b/docs/world api.md index 47734f9dfb72..b1b1ff7cdd9a 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -375,7 +375,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation class MyGameWorld(World): """Insert description of the world/game here.""" game = "My Game" # name of the game/world - options_dataclass = MyGameOptions # options the player can set + options_dataclass = MyGameOptions # options the player can set options: MyGameOptions # typing hints for option results topology_present = True # show path to required location checks in spoiler From 2b46533170e54a8d88cc330e48579af8fe914ce2 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 20 May 2023 20:26:35 -0500 Subject: [PATCH 047/163] fix tests --- Options.py | 5 +---- test/general/TestHelpers.py | 2 +- test/general/TestOptions.py | 2 +- worlds/stardew_valley/test/__init__.py | 6 +++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Options.py b/Options.py index 6a1cc374ea36..e0a880afa7e6 100644 --- a/Options.py +++ b/Options.py @@ -1094,10 +1094,7 @@ def dictify_range(option: typing.Union[Range, SpecialRange]): for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - all_options: typing.Dict[str, AssembleOptions] = { - **per_game_common_options, - **world.option_definitions - } + all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints with open(local_path("data", "options.yaml")) as f: file_data = f.read() diff --git a/test/general/TestHelpers.py b/test/general/TestHelpers.py index b6b1ea470178..543162c0865c 100644 --- a/test/general/TestHelpers.py +++ b/test/general/TestHelpers.py @@ -1,3 +1,4 @@ +from argparse import Namespace from typing import Dict, Optional, Callable from BaseClasses import MultiWorld, CollectionState, Region @@ -13,7 +14,6 @@ def setUp(self) -> None: self.multiworld.game[self.player] = "helper_test_game" self.multiworld.player_name = {1: "Tester"} self.multiworld.set_seed() - self.multiworld.set_default_common_options() def testRegionHelpers(self) -> None: regions: Dict[str, str] = { diff --git a/test/general/TestOptions.py b/test/general/TestOptions.py index b7058183e09c..4a3bd0b02a0a 100644 --- a/test/general/TestOptions.py +++ b/test/general/TestOptions.py @@ -6,6 +6,6 @@ class TestOptions(unittest.TestCase): def testOptionsHaveDocString(self): for gamename, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: - for option_key, option in world_type.option_definitions.items(): + for option_key, option in world_type.options_dataclass.type_hints.items(): with self.subTest(game=gamename, option=option_key): self.assertTrue(option.__doc__) diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index 9d2fac02d937..99e6d4e0f09a 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -1,7 +1,7 @@ from argparse import Namespace from typing import Dict, FrozenSet, Tuple, Any, ClassVar -from BaseClasses import MultiWorld +from BaseClasses import MultiWorld, CollectionState from test.TestBase import WorldTestBase from test.general import gen_steps from .. import StardewValleyWorld @@ -42,12 +42,12 @@ def setup_solo_multiworld(test_options=None, multiworld.game[1] = StardewValleyWorld.game multiworld.player_name = {1: "Tester"} multiworld.set_seed() + multiworld.state = CollectionState(multiworld) args = Namespace() - for name, option in StardewValleyWorld.option_definitions.items(): + for name, option in StardewValleyWorld.options_dataclass.type_hints.items(): value = option(test_options[name]) if name in test_options else option.from_any(option.default) setattr(args, name, {1: value}) multiworld.set_options(args) - multiworld.set_default_common_options() for step in gen_steps: call_all(multiworld, step) From e6424e29d3ea9b8faa14f4445c974dd6b83a2537 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 21 May 2023 01:12:19 -0500 Subject: [PATCH 048/163] fix missing import --- worlds/messenger/Options.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py index 9397ec57cfcc..6a1d1f578e10 100644 --- a/worlds/messenger/Options.py +++ b/worlds/messenger/Options.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice, Toggle, StartInventoryPool +from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice, Toggle, StartInventoryPool,\ + PerGameCommonOptions class MessengerAccessibility(Accessibility): From f189f5d274e52555c988d8ae717212127ec1a9e1 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 25 May 2023 11:08:46 -0500 Subject: [PATCH 049/163] make the documentation definition more accurate --- docs/options api.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/options api.md b/docs/options api.md index 720e393205ba..3f7a9b56a182 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -28,11 +28,12 @@ Choice, and defining `alias_true = option_full`. and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`. As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's -create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our -options: +create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass: ```python # Options.py +from dataclasses import dataclass + from Options import Toggle, PerGameCommonOptions @@ -41,6 +42,7 @@ class StartingSword(Toggle): display_name = "Start With Sword" +@dataclass class ExampleGameOptions(PerGameCommonOptions): starting_sword: StartingSword ``` From 3e1be1e6757bc9f90743d211c21271ed9e9d3e97 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 27 Jun 2023 22:51:34 -0500 Subject: [PATCH 050/163] use options system for loc creation --- worlds/messenger/SubClasses.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index c83f8f5e0892..4d3c1e8d0f3b 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -2,7 +2,7 @@ from BaseClasses import Region, Location, Item, ItemClassification, Entrance, CollectionState from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS -from .Options import Goal +from .Options import Goal, MessengerOptions from .Regions import REGIONS, SEALS, MEGA_SHARDS from .Shop import SHOP_ITEMS, PROG_SHOP_ITEMS, USEFUL_SHOP_ITEMS, FIGURINES @@ -13,17 +13,22 @@ class MessengerRegion(Region): + def __init__(self, name: str, world: MessengerWorld) -> None: super().__init__(name, world.player, world.multiworld) - self.add_locations(self.multiworld.worlds[self.player].location_name_to_id) + self.add_locations(world.location_name_to_id) + if name == "The Shop" and world.options.goal > Goal.option_open_music_box: + self.locations.append(MessengerLocation("Shop Chest", self, None)) + if world.options.shuffle_seals and name in SEALS: + self.create_seal_locs(world.location_name_to_id) + if world.options.shuffle_shards and name in MEGA_SHARDS: + self.create_shard_locs(world.location_name_to_id) world.multiworld.regions.append(self) def add_locations(self, name_to_id: Dict[str, int]) -> None: for loc in REGIONS[self.name]: self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None))) if self.name == "The Shop": - if self.multiworld.goal[self.player] > Goal.option_open_music_box: - self.locations.append(MessengerLocation("Shop Chest", self, None)) self.locations += [MessengerShopLocation(f"The Shop - {shop_loc}", self, name_to_id[f"The Shop - {shop_loc}"]) for shop_loc in SHOP_ITEMS] @@ -31,12 +36,12 @@ def add_locations(self, name_to_id: Dict[str, int]) -> None: for figurine in FIGURINES] elif self.name == "Tower HQ": self.locations.append(MessengerLocation("Money Wrench", self, name_to_id["Money Wrench"])) - if self.multiworld.shuffle_seals[self.player] and self.name in SEALS: - self.locations += [MessengerLocation(seal_loc, self, name_to_id[seal_loc]) - for seal_loc in SEALS[self.name]] - if self.multiworld.shuffle_shards[self.player] and self.name in MEGA_SHARDS: - self.locations += [MessengerLocation(shard, self, name_to_id[shard]) - for shard in MEGA_SHARDS[self.name]] + + def create_seal_locs(self, name_to_id: Dict[str, int]) -> None: + self.locations += [MessengerLocation(seal_loc, self, name_to_id[seal_loc]) for seal_loc in SEALS[self.name]] + + def create_shard_locs(self, name_to_id: Dict[str, int]) -> None: + self.locations += [MessengerLocation(shard_loc, self, name_to_id[shard_loc]) for shard_loc in MEGA_SHARDS[self.name]] def add_exits(self, exits: Set[str]) -> None: for exit in exits: From fc93f971ce5dbeca9c6416d6b2e86be2cb30d03b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 27 Jun 2023 23:08:28 -0500 Subject: [PATCH 051/163] type cast MessengerWorld --- worlds/messenger/SubClasses.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index 4d3c1e8d0f3b..4f336ac255e9 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -1,15 +1,13 @@ -from typing import Set, TYPE_CHECKING, Optional, Dict +from typing import Set, TYPE_CHECKING, Optional, Dict, cast from BaseClasses import Region, Location, Item, ItemClassification, Entrance, CollectionState from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS -from .Options import Goal, MessengerOptions +from .Options import Goal from .Regions import REGIONS, SEALS, MEGA_SHARDS from .Shop import SHOP_ITEMS, PROG_SHOP_ITEMS, USEFUL_SHOP_ITEMS, FIGURINES if TYPE_CHECKING: from . import MessengerWorld -else: - MessengerWorld = object class MessengerRegion(Region): @@ -62,11 +60,11 @@ def __init__(self, name: str, parent: MessengerRegion, loc_id: Optional[int]) -> class MessengerShopLocation(MessengerLocation): def cost(self) -> int: name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped - world: MessengerWorld = self.parent_region.multiworld.worlds[self.player] + world: MessengerWorld = cast(MessengerWorld, self.parent_region.multiworld.worlds[self.player]) return world.shop_prices.get(name, world.figurine_prices.get(name)) def can_afford(self, state: CollectionState) -> bool: - world: MessengerWorld = state.multiworld.worlds[self.player] + world: MessengerWorld = cast(MessengerWorld, state.multiworld.worlds[self.player]) cost = self.cost() * 2 if cost >= 1000: cost *= 2 From 0b9959e7dbe17198e5b106c26f12266d8b66ca1d Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 27 Jun 2023 23:28:19 -0500 Subject: [PATCH 052/163] fix typo and use quotes for cast --- worlds/messenger/SubClasses.py | 6 +++--- worlds/messenger/__init__.py | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index 4f336ac255e9..925e2d4440f3 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -12,7 +12,7 @@ class MessengerRegion(Region): - def __init__(self, name: str, world: MessengerWorld) -> None: + def __init__(self, name: str, world: "MessengerWorld") -> None: super().__init__(name, world.player, world.multiworld) self.add_locations(world.location_name_to_id) if name == "The Shop" and world.options.goal > Goal.option_open_music_box: @@ -60,11 +60,11 @@ def __init__(self, name: str, parent: MessengerRegion, loc_id: Optional[int]) -> class MessengerShopLocation(MessengerLocation): def cost(self) -> int: name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped - world: MessengerWorld = cast(MessengerWorld, self.parent_region.multiworld.worlds[self.player]) + world: MessengerWorld = cast("MessengerWorld", self.parent_region.multiworld.worlds[self.player]) return world.shop_prices.get(name, world.figurine_prices.get(name)) def can_afford(self, state: CollectionState) -> bool: - world: MessengerWorld = cast(MessengerWorld, state.multiworld.worlds[self.player]) + world: MessengerWorld = cast("MessengerWorld", state.multiworld.worlds[self.player]) cost = self.cost() * 2 if cost >= 1000: cost *= 2 diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index b45c5fd60f1b..36d13b686c80 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -107,9 +107,7 @@ def create_items(self) -> None: # amount we need to put in the itempool and precollect based on that notes = [note for note in NOTES if note not in self.multiworld.precollected_items[self.player]] self.multiworld.per_slot_randoms[self.player].shuffle(notes) - precollected_notes_amount = NotesNeeded.range_end - \ - self.options.notes_needed[self.player] - \ - (len(NOTES) - len(notes)) + precollected_notes_amount = NotesNeeded.range_end - self.options.notes_needed - (len(NOTES) - len(notes)) if precollected_notes_amount: for note in notes[:precollected_notes_amount]: self.multiworld.push_precollected(self.create_item(note)) From 266acd2ee42cd3fa98b100da2a7ffb3d99326eb9 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 18 Jul 2023 22:39:44 -0500 Subject: [PATCH 053/163] LTTP: set random seed in tests --- worlds/alttp/test/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/alttp/test/__init__.py b/worlds/alttp/test/__init__.py index 958b92b72567..5baaa7e88e61 100644 --- a/worlds/alttp/test/__init__.py +++ b/worlds/alttp/test/__init__.py @@ -9,6 +9,7 @@ class LTTPTestBase(unittest.TestCase): def world_setup(self): self.multiworld = MultiWorld(1) self.multiworld.state = CollectionState(self.multiworld) + self.multiworld.set_seed(None) args = Namespace() for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items(): setattr(args, name, {1: option.from_any(getattr(option, "default"))}) From 4596be95591b6c81fff379e4bbe3aab0ca604256 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 23 Jul 2023 16:37:51 -0500 Subject: [PATCH 054/163] ArchipIdle: remove change here as it's default on AutoWorld --- worlds/archipidle/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/worlds/archipidle/__init__.py b/worlds/archipidle/__init__.py index 86636b6c3098..2d182f31dc20 100644 --- a/worlds/archipidle/__init__.py +++ b/worlds/archipidle/__init__.py @@ -1,5 +1,4 @@ from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification -from Options import PerGameCommonOptions from .Items import item_table from .Rules import set_rules from ..AutoWorld import World, WebWorld @@ -38,8 +37,6 @@ class ArchipIDLEWorld(World): hidden = (datetime.now().month != 4) # ArchipIDLE is only visible during April web = ArchipIDLEWebWorld() - options_dataclass = PerGameCommonOptions - item_name_to_id = {} start_id = 9000 for item in item_table: From 41ed5451c58aa517c18dfd36c010ff49a9e0b788 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 23 Jul 2023 16:38:13 -0500 Subject: [PATCH 055/163] Stardew: Need to set state because `set_default_common_options` used to --- worlds/stardew_valley/test/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index b17048f29d58..5bd3014f943c 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -109,6 +109,7 @@ def setup_solo_multiworld(test_options=None, seed=None, multiworld = MultiWorld(1) multiworld.game[1] = StardewValleyWorld.game multiworld.player_name = {1: "Tester"} + multiworld.state = CollectionState(multiworld) multiworld.set_seed(seed) # print(f"Seed: {multiworld.seed}") # Uncomment to print the seed for every test args = Namespace() From 26976b8f427c85678735c923237ab759f44c3e84 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 23 Jul 2023 16:39:06 -0500 Subject: [PATCH 056/163] The Messenger: update shop rando and helpers to new system; optimize imports --- worlds/messenger/Constants.py | 4 ++-- worlds/messenger/Options.py | 7 ++++--- worlds/messenger/Regions.py | 2 +- worlds/messenger/Rules.py | 10 +++++----- worlds/messenger/Shop.py | 5 ++--- worlds/messenger/SubClasses.py | 10 +++++----- worlds/messenger/__init__.py | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/worlds/messenger/Constants.py b/worlds/messenger/Constants.py index 121584da0555..e0e471fd4cd5 100644 --- a/worlds/messenger/Constants.py +++ b/worlds/messenger/Constants.py @@ -1,7 +1,7 @@ +from .Shop import FIGURINES, SHOP_ITEMS + # items # listing individual groups first for easy lookup -from .Shop import SHOP_ITEMS, FIGURINES - NOTES = [ "Key of Hope", "Key of Chaos", diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py index 91f09b5ff01e..1da544bee70c 100644 --- a/worlds/messenger/Options.py +++ b/worlds/messenger/Options.py @@ -1,9 +1,10 @@ from dataclasses import dataclass from typing import Dict -from schema import Schema, Or, And, Optional -from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice, Toggle, OptionDict, StartInventoryPool,\ - PerGameCommonOptions +from schema import And, Optional, Or, Schema + +from Options import Accessibility, Choice, DeathLink, DefaultOnToggle, OptionDict, PerGameCommonOptions, Range, \ + StartInventoryPool, Toggle class MessengerAccessibility(Accessibility): diff --git a/worlds/messenger/Regions.py b/worlds/messenger/Regions.py index 2bfd3cab8433..0178bea95841 100644 --- a/worlds/messenger/Regions.py +++ b/worlds/messenger/Regions.py @@ -1,4 +1,4 @@ -from typing import Dict, Set, List +from typing import Dict, List, Set REGIONS: Dict[str, List[str]] = { "Menu": [], diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index 2799e5ffb326..23f76217c78c 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -1,9 +1,9 @@ -from typing import Dict, Callable, TYPE_CHECKING +from typing import Callable, Dict, TYPE_CHECKING -from BaseClasses import CollectionState, MultiWorld -from worlds.generic.Rules import set_rule, allow_self_locking_items, add_rule -from .Options import MessengerAccessibility, Goal +from BaseClasses import CollectionState +from worlds.generic.Rules import add_rule, allow_self_locking_items, set_rule from .Constants import NOTES, PHOBEKINS +from .Options import Goal, MessengerAccessibility from .SubClasses import MessengerShopLocation if TYPE_CHECKING: @@ -215,7 +215,7 @@ def set_messenger_rules(self) -> None: for loc, rule in self.extra_rules.items(): if not self.world.options.shuffle_seals and "Seal" in loc: continue - if not self.world.multiworld.shuffle_shards[self.player] and "Shard" in loc: + if not self.world.options.shuffle_shards and "Shard" in loc: continue add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or") diff --git a/worlds/messenger/Shop.py b/worlds/messenger/Shop.py index 68f415349b7b..be82c09058c2 100644 --- a/worlds/messenger/Shop.py +++ b/worlds/messenger/Shop.py @@ -1,4 +1,3 @@ -from random import Random from typing import Dict, TYPE_CHECKING, NamedTuple, Tuple, List if TYPE_CHECKING: @@ -73,8 +72,8 @@ class ShopData(NamedTuple): def shuffle_shop_prices(world: MessengerWorld) -> Tuple[Dict[str, int], Dict[str, int]]: - shop_price_mod = world.multiworld.shop_price[world.player].value - shop_price_planned = world.multiworld.shop_price_plan[world.player] + shop_price_mod = world.options.shop_price.value + shop_price_planned = world.options.shop_price_plan shop_prices: Dict[str, int] = {} figurine_prices: Dict[str, int] = {} diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index e1e717108a60..36ce892ec563 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -1,10 +1,10 @@ -from typing import TYPE_CHECKING, Optional, cast +from typing import Optional, TYPE_CHECKING, cast -from BaseClasses import Region, Location, Item, ItemClassification, CollectionState -from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS +from BaseClasses import CollectionState, Item, ItemClassification, Location, Region +from .Constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS from .Options import Goal -from .Regions import REGIONS, SEALS, MEGA_SHARDS -from .Shop import SHOP_ITEMS, PROG_SHOP_ITEMS, USEFUL_SHOP_ITEMS, FIGURINES +from .Regions import MEGA_SHARDS, REGIONS, SEALS +from .Shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS if TYPE_CHECKING: from . import MessengerWorld diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index f85ae6084361..afb0dbe6a89f 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -118,7 +118,7 @@ def create_items(self) -> None: self.options.total_seals.value) if total_seals < self.total_seals: logging.warning(f"Not enough locations for total seals setting " - f"({self.multiworld.total_seals[self.player].value}). Adjusting to {total_seals}") + f"({self.options.total_seals}). Adjusting to {total_seals}") self.total_seals = total_seals self.required_seals = int(self.options.percent_seals_required.value / 100 * self.total_seals) From 7a114d472aa00095cb4dd20c37f6cd105c0aadf6 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 23 Jul 2023 17:02:43 -0500 Subject: [PATCH 057/163] Add a kwarg to `as_dict` to do the casing for you --- Options.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Options.py b/Options.py index 501a9a68af90..9e063f2a5dd5 100644 --- a/Options.py +++ b/Options.py @@ -912,15 +912,30 @@ class CommonOptions(metaclass=OptionsMetaProperty): progression_balancing: ProgressionBalancing accessibility: Accessibility - def as_dict(self, *option_names: str) -> typing.Dict[str, typing.Any]: + def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]: """ - Pass the option_names you would like returned as a dictionary as strings. Returns a dictionary of [str, Option.value] + + :param option_names: names of the options to return + :param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` """ option_results = {} for option_name in option_names: if option_name in type(self).type_hints: - option_results[option_name] = getattr(self, option_name).value + if casing == "snake": + display_name = option_name + elif casing == "camel": + split_name = [name.title() for name in option_name.split("_")] + split_name[0] = split_name[0].lower() + display_name = "".join(split_name) + elif casing == "pascal": + display_name = "".join([name.title() for name in option_name.split("_")]) + elif casing == "kebab": + display_name = option_name.replace("_", "-") + else: + raise ValueError(f"{casing} is invalid casing for as_dict. " + "Valid names are 'snake', 'camel', 'pascal', 'kebab'.") + option_results[display_name] = getattr(self, option_name).value else: raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") return option_results From bb06b3a50bca0a99569fb53f3348d2961e449336 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 23 Jul 2023 17:02:58 -0500 Subject: [PATCH 058/163] RoR2: use new kwarg for less code --- worlds/ror2/__init__.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index a5294f91fd91..4955827d504d 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -188,16 +188,9 @@ def fill_slot_data(self): options_dict = self.options.as_dict("item_pickup_step", "shrine_use_step", "goal", "total_locations", "chests_per_stage", "shrines_per_stage", "scavengers_per_stage", "scanner_per_stage", "altars_per_stage", "total_revivals", "start_with_revive", - "final_stage_death", "death_link") - cased_dict = {} - for key, value in options_dict.items(): - split_name = [name.title() for name in key.split("_")] - split_name[0] = split_name[0].lower() - new_name = "".join(split_name) - cased_dict[new_name] = value - + "final_stage_death", "death_link", casing="camel") return { - **cased_dict, + **options_dict, "seed": "".join(self.multiworld.per_slot_randoms[self.player].choice(string.digits) for _ in range(16)), } From f5da39e7031a3b2c0f6d439e2536a8852c40b348 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Sep 2023 19:30:51 -0500 Subject: [PATCH 059/163] RoR2: revert some accidental reverts --- worlds/ror2/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 23ea7bb00b47..22c65dd9deb7 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -100,19 +100,19 @@ def create_items(self) -> None: for env_name, _ in environments_pool.items(): itempool += [env_name] - if self.multiworld.goal[self.player] == "classic": + if self.options.goal == "classic": # classic mode - total_locations = self.multiworld.total_locations[self.player].value + total_locations = self.options.total_locations.value else: # explore mode total_locations = len( orderedstage_location.get_locations( - chests=self.multiworld.chests_per_stage[self.player].value, - shrines=self.multiworld.shrines_per_stage[self.player].value, - scavengers=self.multiworld.scavengers_per_stage[self.player].value, - scanners=self.multiworld.scanner_per_stage[self.player].value, - altars=self.multiworld.altars_per_stage[self.player].value, - dlc_sotv=self.multiworld.dlc_sotv[self.player].value + chests=self.options.chests_per_stage.value, + shrines=self.options.shrines_per_stage.value, + scavengers=self.options.scavengers_per_stage.value, + scanners=self.options.scanner_per_stage.value, + altars=self.options.altars_per_stage.value, + dlc_sotv=self.options.dlc_sotv.value ) ) # Create junk items From 9cf454ba05ce86b3a7caf3f9b1348cdbaca60e52 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Sep 2023 19:31:08 -0500 Subject: [PATCH 060/163] The Messenger: remove an unnecessary variable --- worlds/messenger/SubClasses.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py index 3fe016770cdf..bec751432366 100644 --- a/worlds/messenger/SubClasses.py +++ b/worlds/messenger/SubClasses.py @@ -70,8 +70,7 @@ def cost(self) -> int: def can_afford(self, state: CollectionState) -> bool: world = cast("MessengerWorld", state.multiworld.worlds[self.player]) - cost = self.cost - can_afford = state.has("Shards", self.player, min(cost, world.total_shards)) + can_afford = state.has("Shards", self.player, min(self.cost, world.total_shards)) if "Figurine" in self.name: can_afford = state.has("Money Wrench", self.player) and can_afford\ and state.can_reach("Money Wrench", "Location", self.player) From 37f6d8b448c0e8e45a69132feee91accafcfdcab Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Fri, 1 Sep 2023 21:07:19 -0700 Subject: [PATCH 061/163] remove TypeVar that isn't used --- BaseClasses.py | 2 +- Options.py | 15 +++++++-------- worlds/AutoWorld.py | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index f01c6fc2cd89..877ec931ebb2 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -237,7 +237,7 @@ def set_options(self, args: Namespace) -> None: option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) # TODO - remove this loop once all worlds use options dataclasses - options_dataclass: typing.Type[Options.GameOptions] = self.worlds[player].options_dataclass + options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] for option_key in options_dataclass.type_hints}) diff --git a/Options.py b/Options.py index 9adea3261f0d..8801c75a4a3b 100644 --- a/Options.py +++ b/Options.py @@ -899,16 +899,19 @@ class ProgressionBalancing(SpecialRange): } -class OptionsMetaProperty(type): - def __new__(mcs, name, bases, attrs): - for attr, attr_type in attrs.items(): +class OptionsMetaProperty(abc.ABCMeta): + def __new__(mcs, + name: str, + bases: typing.Tuple[type, ...], + attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty": + for attr_type in attrs.values(): assert not isinstance(attr_type, AssembleOptions),\ f"Options for {name} should be type hinted on the class, not assigned" return super().__new__(mcs, name, bases, attrs) @property @functools.lru_cache(maxsize=None) - def type_hints(cls) -> typing.Dict[str, AssembleOptions]: + def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]: """Returns type hints of the class as a dictionary.""" return typing.get_type_hints(cls) @@ -1077,10 +1080,6 @@ class PerGameCommonOptions(CommonOptions): item_links: ItemLinks -GameOptions = typing.TypeVar("GameOptions", bound=PerGameCommonOptions) - - - def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): import os diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index d750cbefa896..9bdd5af31d97 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -8,7 +8,7 @@ from typing import Any, Callable, ClassVar, Dict, Set, Tuple, FrozenSet, List, Optional, TYPE_CHECKING, TextIO, Type, \ Union -from Options import GameOptions, PerGameCommonOptions +from Options import PerGameCommonOptions from BaseClasses import CollectionState if TYPE_CHECKING: @@ -170,7 +170,7 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - options_dataclass: Type[GameOptions] = PerGameCommonOptions + options_dataclass: ClassVar[Type[PerGameCommonOptions]] = PerGameCommonOptions """link your Options mapping""" options: PerGameCommonOptions """resulting options for the player of this world""" From d381d1dfccf76468a0c66c557ccd7ca977e124fb Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Sat, 2 Sep 2023 07:46:46 -0700 Subject: [PATCH 062/163] CommonOptions not abstract --- Options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Options.py b/Options.py index 8801c75a4a3b..8ce3d90a9638 100644 --- a/Options.py +++ b/Options.py @@ -899,7 +899,7 @@ class ProgressionBalancing(SpecialRange): } -class OptionsMetaProperty(abc.ABCMeta): +class OptionsMetaProperty(type): def __new__(mcs, name: str, bases: typing.Tuple[type, ...], From 6d418f90a70782107877c0920922546ae346d1e5 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Sat, 2 Sep 2023 17:06:08 -0500 Subject: [PATCH 063/163] Docs: fix mistake in options api.md Co-authored-by: Doug Hoskisson --- docs/options api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/options api.md b/docs/options api.md index 3f7a9b56a182..2c86833800c7 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -65,7 +65,7 @@ class ExampleWorld(World): ### Option Checking Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after world instantiation. These are created as attributes on the MultiWorld and can be accessed with -`self.o.my_option_name`. This is the option class, which supports direct comparison methods to +`self.options.my_option_name`. This is an instance of the option class, which supports direct comparison methods to relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is the option class's `value` attribute. For our example above we can do a simple check: ```python From 2e30f07533977d154027c88458e1c423c300dd42 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 2 Sep 2023 23:25:00 -0500 Subject: [PATCH 064/163] create options for item link worlds --- BaseClasses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BaseClasses.py b/BaseClasses.py index 877ec931ebb2..d37237b8c329 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -202,11 +202,15 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu self.player_types[new_id] = NetUtils.SlotType.group self._region_cache[new_id] = {} world_type = AutoWorld.AutoWorldRegister.world_types[game] + # TODO - remove this loop once all worlds use options dataclasses for option_key, option in world_type.options_dataclass.type_hints.items(): getattr(self, option_key)[new_id] = option(option.default) self.worlds[new_id] = world_type(self, new_id) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) + self.worlds[new_id].options = world_type.options_dataclass(**{ + option_key: option(option.default) for option_key, option in world_type.options_dataclass.type_hints.items() + }) self.player_name[new_id] = name new_group = self.groups[new_id] = Group(name=name, game=game, players=players, From 8583c2a03c515e8dc8303848a158a9fe7fc36b36 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 2 Sep 2023 23:25:33 -0500 Subject: [PATCH 065/163] revert accidental doc removals --- docs/world api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/world api.md b/docs/world api.md index 8b395a00812b..26f97bc31275 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -395,6 +395,7 @@ class MyGameWorld(World): game = "My Game" # name of the game/world options_dataclass = MyGameOptions # options the player can set options: MyGameOptions # typing hints for option results + settings: typing.ClassVar[MyGameSettings] # will be automatically assigned from type hint topology_present = True # show path to required location checks in spoiler # ID of first item and location, could be hard-coded but code may be easier @@ -682,7 +683,7 @@ def generate_output(self, output_directory: str): "seed": self.multiworld.seed_name, # to verify the server's multiworld "slot": self.multiworld.player_name[self.player], # to connect to server "items": {location.name: location.item.name - if location.item.player == self.player else "Remote" + if location.item.player == self.player else "Remote" for location in self.multiworld.get_filled_locations(self.player)}, # store start_inventory from player's .yaml # make sure to mark as not remote_start_inventory when connecting if stored in rom/mod From 833f09c2d37b28a3c7e4ddb63f74682aec7924b1 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 27 Sep 2023 17:37:30 -0500 Subject: [PATCH 066/163] Item Links: set default options on group --- worlds/AutoWorld.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 76d183c32f8e..9a8b6a56ef36 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -372,16 +372,14 @@ def get_filler_item_name(self) -> str: def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World: """Creates a group, which is an instance of World that is responsible for multiple others. An example case is ItemLinks creating these.""" - import Options - - for option_key, option in cls.option_definitions.items(): - getattr(multiworld, option_key)[new_player_id] = option(option.default) - for option_key, option in Options.common_options.items(): - getattr(multiworld, option_key)[new_player_id] = option(option.default) - for option_key, option in Options.per_game_common_options.items(): + # 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(option.default) + group = cls(multiworld, new_player_id) + group.options = cls.options_dataclass(**{option_key: option(option.default) + for option_key, option in cls.options_dataclass.type_hints.items()}) - return cls(multiworld, new_player_id) + return group # decent place to implement progressive items, in most cases can stay as-is def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]: From 06dae6e4be60b29055a25215f56601a5d0f8db2b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 28 Sep 2023 00:45:15 -0500 Subject: [PATCH 067/163] Messenger: Limited Movement option first draft --- worlds/messenger/Options.py | 6 ++++ worlds/messenger/Rules.py | 1 + worlds/messenger/__init__.py | 51 ++++++++++++++++++++++++------ worlds/messenger/test/TestLogic.py | 17 ++++++++++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py index 1da544bee70c..2f64fc44056f 100644 --- a/worlds/messenger/Options.py +++ b/worlds/messenger/Options.py @@ -38,6 +38,11 @@ class MegaShards(Toggle): display_name = "Shuffle Mega Time Shards" +class LimitedMovement(Toggle): + """Removes either rope dart or wingsuit from the itempool. Forces seals to be shuffled, and logic to hard.""" + display_name = "Limited Movement" + + class Goal(Choice): """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" display_name = "Goal" @@ -139,6 +144,7 @@ class MessengerOptions(PerGameCommonOptions): logic_level: Logic shuffle_seals: PowerSeals shuffle_shards: MegaShards + limited_movement: LimitedMovement goal: Goal music_box: MusicBox notes_needed: NotesNeeded diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index 23f76217c78c..2aa5eeceee29 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -174,6 +174,7 @@ def __init__(self, world: MessengerWorld) -> None: "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) or self.has_windmill(state) or self.has_dart(state), + "Music Box": self.has_vertical, }) self.location_rules.update({ diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index efc8bfc60244..f931b7490411 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,13 +1,14 @@ import logging -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, cast from BaseClasses import Tutorial, ItemClassification, CollectionState, Item, MultiWorld +from Options import Accessibility from worlds.AutoWorld import World, WebWorld from .Constants import NOTES, PHOBEKINS, ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER from .Options import MessengerOptions, NotesNeeded, Goal, PowerSeals, Logic from .Regions import REGIONS, REGION_CONNECTIONS, SEALS, MEGA_SHARDS from .Shop import SHOP_ITEMS, shuffle_shop_prices, FIGURINES -from .SubClasses import MessengerRegion, MessengerItem +from .SubClasses import MessengerLocation, MessengerRegion, MessengerItem from . import Rules @@ -69,19 +70,24 @@ class MessengerWorld(World): total_seals: int = 0 required_seals: int = 0 - total_shards: int + total_shards: int = 0 shop_prices: Dict[str, int] figurine_prices: Dict[str, int] _filler_items: List[str] - - def __init__(self, multiworld: MultiWorld, player: int): + unreachable_locs: List[MessengerLocation] + + def __init__(self, multiworld: "MultiWorld", player: int): super().__init__(multiworld, player) - self.total_shards = 0 + self.unreachable_locs = [] def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: self.options.shuffle_seals.value = PowerSeals.option_true self.total_seals = self.options.total_seals.value + + if self.options.limited_movement: + self.options.shuffle_seals.value = PowerSeals.option_true + self.options.logic_level.value = Logic.option_hard self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) @@ -92,15 +98,21 @@ def create_regions(self) -> None: def create_items(self) -> None: # create items that are always in the item pool + main_movement_items = ["Rope Dart", "Wingsuit"] itempool: List[MessengerItem] = [ self.create_item(item) for item in self.item_name_to_id if item not in { - "Power Seal", *NOTES, *FIGURINES, + "Power Seal", *NOTES, *FIGURINES, *main_movement_items, *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, } and "Time Shard" not in item ] + + if self.options.limited_movement: + itempool.append(self.create_item(self.random.choice(main_movement_items))) + else: + itempool += [self.create_item(move_item) for move_item in main_movement_items] if self.options.goal == Goal.option_open_music_box: # make a list of all notes except those in the player's defined starting inventory, and adjust the @@ -130,16 +142,26 @@ def create_items(self) -> None: seals[i].classification = ItemClassification.progression_skip_balancing itempool += seals - remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) + self.multiworld.itempool += itempool + if self.options.limited_movement and not self.options.accessibility == Accessibility.option_minimal: + # hardcoding these until i figure out a better solution + # need to figure out which locations are inaccessible with the missing item, and create filler based on + # that count + if self.options.shuffle_shards: + remaining_fill = 96 - len(self.multiworld.get_filled_locations(self.player)) + else: + remaining_fill = 66 - len(self.multiworld.get_filled_locations(self.player)) + else: + remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) if remaining_fill < 10: self._filler_items = self.random.choices( list(FILLER)[2:], weights=list(FILLER.values())[2:], k=remaining_fill ) - itempool += [self.create_filler() for _ in range(remaining_fill)] + filler = [self.create_filler() for _ in range(remaining_fill)] - self.multiworld.itempool += itempool + self.multiworld.itempool += filler def set_rules(self) -> None: logic = self.options.logic_level @@ -149,6 +171,15 @@ def set_rules(self) -> None: Rules.MessengerHardRules(self).set_messenger_rules() else: Rules.MessengerOOBRules(self).set_messenger_rules() + + def generate_basic(self) -> None: + if self.options.limited_movement and not self.options.accessibility == Accessibility.option_minimal: + all_state = self.multiworld.get_all_state(False) + reachable_locs = self.multiworld.get_reachable_locations(all_state, self.player) + unreachable_locs = list(set(self.multiworld.get_locations(self.player)) - set(reachable_locs)) + for loc in unreachable_locs: + loc.place_locked_item(self.create_item("Time Shard")) + loc.access_rule = lambda state: True def fill_slot_data(self) -> Dict[str, Any]: shop_prices = {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()} diff --git a/worlds/messenger/test/TestLogic.py b/worlds/messenger/test/TestLogic.py index 45b0d0dab629..307dd36df801 100644 --- a/worlds/messenger/test/TestLogic.py +++ b/worlds/messenger/test/TestLogic.py @@ -1,5 +1,8 @@ +from typing import cast + from BaseClasses import ItemClassification from . import MessengerTestBase +from .. import Logic, MessengerWorld, PowerSeals class HardLogicTest(MessengerTestBase): @@ -94,3 +97,17 @@ def testAccess(self) -> None: with self.subTest("Default unreachables", location=loc): self.assertFalse(self.can_reach_location(loc)) self.assertBeatable(True) + + +class LimitedMovementTest(MessengerTestBase): + options = { + "limited_movement": "true", + "shuffle_seals": "false", + "shuffle_shards": "true", + } + + def testOptions(self) -> None: + """Tests that options were correctly changed.""" + world = cast(MessengerWorld, self.multiworld.worlds[self.player]) + self.assertEqual(PowerSeals.option_true, world.options.shuffle_seals) + self.assertEqual(Logic.option_hard, world.options.logic_level) From 3927342456146ed175801b7aecf01e0d99db3a89 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 7 Oct 2023 17:00:35 -0500 Subject: [PATCH 068/163] The Messenger: add automated setup through the launcher --- worlds/messenger/__init__.py | 51 ++++++++---- worlds/messenger/client_setup.py | 134 ++++++++++++++++++++++++++++++ worlds/messenger/docs/setup_en.md | 11 +++ 3 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 worlds/messenger/client_setup.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index b37f23749df5..64f210047e6a 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,14 +1,29 @@ import logging -from typing import Dict, Any, List, Optional - -from BaseClasses import Tutorial, ItemClassification, CollectionState, Item, MultiWorld -from worlds.AutoWorld import World, WebWorld -from .Constants import NOTES, PHOBEKINS, ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER -from .Options import messenger_options, NotesNeeded, Goal, PowerSeals, Logic -from .Regions import REGIONS, REGION_CONNECTIONS, SEALS, MEGA_SHARDS -from .Shop import SHOP_ITEMS, shuffle_shop_prices, FIGURINES -from .SubClasses import MessengerRegion, MessengerItem +from typing import Any, ClassVar, Dict, List, Optional + +from BaseClasses import CollectionState, Item, ItemClassification, MultiWorld, Tutorial +from settings import FilePath, Group +from worlds.AutoWorld import WebWorld, World +from worlds.LauncherComponents import Component, Type, components from . import Rules +from .Constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS +from .Options import Goal, Logic, NotesNeeded, PowerSeals, messenger_options +from .Regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS +from .Shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices +from .SubClasses import MessengerItem, MessengerRegion +from .client_setup import launch_game + +components.append( + Component("The Messenger", cli=True, component_type=Type.CLIENT, func=launch_game) +) + + +class MessengerSettings(Group): + class GamePath(FilePath): + description = "The Messenger game executable" + is_exe = True + + game_path: GamePath = GamePath("TheMessenger.exe") class MessengerWeb(WebWorld): @@ -35,16 +50,9 @@ class MessengerWorld(World): adventure full of thrills, surprises, and humor. """ game = "The Messenger" - - item_name_groups = { - "Notes": set(NOTES), - "Keys": set(NOTES), - "Crest": {"Sun Crest", "Moon Crest"}, - "Phobe": set(PHOBEKINS), - "Phobekin": set(PHOBEKINS), - } - option_definitions = messenger_options + settings_key = "messenger_settings" + settings: ClassVar[MessengerSettings] base_offset = 0xADD_000 item_name_to_id = {item: item_id @@ -60,6 +68,13 @@ class MessengerWorld(World): *FIGURINES, "Money Wrench", ], base_offset)} + item_name_groups = { + "Notes": set(NOTES), + "Keys": set(NOTES), + "Crest": {"Sun Crest", "Moon Crest"}, + "Phobe": set(PHOBEKINS), + "Phobekin": set(PHOBEKINS), + } data_version = 3 required_client_version = (0, 4, 0) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py new file mode 100644 index 000000000000..4d9b1c782e08 --- /dev/null +++ b/worlds/messenger/client_setup.py @@ -0,0 +1,134 @@ +import logging +import os.path +import subprocess +import sys +import tomllib +from tkinter.messagebox import askyesnocancel +from typing import Any, Optional +from zipfile import ZipFile + +import requests + +from Launcher import launch +from Utils import messagebox, tuplize_version + +path: str +folder: str + + +def courier_installed() -> bool: + """Check if Courier is installed""" + return os.path.exists(os.path.join(folder, "miniinstaller-log.txt")) + + +def install_courier() -> Optional[bool]: + """Installs latest version of Courier""" + should_install = askyesnocancel("Install Courier", + "No Courier installation detected. Would you like to install now?") + if not should_install: + return should_install + + # can't use latest since courier uses pre-release tags + courier_url = "https://api.github.com/repos/Brokemia/Courier/releases" + assets = request_data(courier_url)[0]["assets"] + latest_download = assets[-1]["browser_download_url"] + + remote_file = requests.get(latest_download) + temp_file = assets[-1]["name"] + with open(temp_file, "wb") as f: + f.write(remote_file.content) + + with ZipFile(temp_file, "r") as data: + data.extractall(folder) + os.remove(temp_file) + + working_directory = os.getcwd() + os.chdir(folder) + installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe")) + failure = installer.wait() + if failure: + messagebox("Failure", "Failed to install Courier", True) + os.chdir(working_directory) + if courier_installed(): + messagebox("Success!", "Courier successfully installed!") + return True + messagebox("Failure", "Failed to install Courier", True) + return False + + +def mod_installed() -> bool: + """Check if the mod is installed""" + return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) + + +def request_data(url: str) -> Any: + logging.info(f"requesting {url}") + response = requests.get(url) + if response.status_code == 200: # success + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + logging.error(f"Unable to fetch version update data. (status code {response.status_code})") + sys.exit(1) + else: + logging.error(f"Unable to fetch version update data. (status code {response.status_code})") + sys.exit(1) + return data + + +def update_mod() -> Optional[bool]: + """Check if the mod needs to be updated, and update if so""" + url = "https://raw.githubusercontent.com/alwaysintreble/TheMessengerRandomizerModAP/archipelago/courier.toml" + latest_version = tomllib.loads(requests.get(url).text).get("version") + + toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") + with open(toml_path, "rb") as f: + installed_version = tomllib.load(f).get("version") + + should_update = tuplize_version(latest_version) > tuplize_version(installed_version) + if should_update: + should_update = askyesnocancel("Update Mod", + "Old mod version detected. Would you like to update now?") + + if not should_update: + return should_update + + install_mod() + + +def install_mod() -> None: + """Installs latest version of the mod""" + url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" + assets = request_data(url)["assets"] + release_url = assets[0]["browser_download_url"] + + remote_file = requests.get(release_url) + temp_file = assets[0]["name"] + with open(temp_file, "wb") as f: + f.write(remote_file.content) + + with ZipFile(temp_file, "r") as data: + target = os.path.join(folder, "Mods") + data.extractall(target) + os.remove(temp_file) + messagebox("Success!", "Latest mod successfully installed!") + + +def launch_game() -> None: + """Check the game installation, then launch it""" + from . import MessengerWorld + global path, folder + path = MessengerWorld.settings.game_path + folder = os.path.dirname(path) + if not courier_installed(): + if not install_courier(): + return + if not mod_installed(): + should_install = askyesnocancel("Install Mod", + "No randomizer mod detected. Would you like to install now?") + if not should_install: + return + else: + if update_mod() is None: + return + launch(path) diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md index d93d13b27483..d15769e4503e 100644 --- a/worlds/messenger/docs/setup_en.md +++ b/worlds/messenger/docs/setup_en.md @@ -3,6 +3,7 @@ ## Quick Links - [Game Info](../../../../games/The%20Messenger/info/en) - [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Archipelago Software](https://github.com/ArchipelagoMW/Archipelago/releases/latest) - [Courier Github](https://github.com/Brokemia/Courier) - [The Messenger Randomizer AP Github](https://github.com/alwaysintreble/TheMessengerRandomizerModAP) - [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) @@ -10,6 +11,16 @@ ## Installation +### Automated + +1. Read the [Game Info Page](../../../../games/The%20Messenger/info/en) for how the game works, caveats and known issues +2. Download and install the [Latest release of Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest) +3. Run ArchipelagoLauncher from your Archipelago installation +4. Click "The Messenger" and follow the prompts +5. Clicking "The Messenger" again will check for updates and launch the game. + +### Manual + 1. Read the [Game Info Page](../../../../games/The%20Messenger/info/en) for how the game works, caveats and known issues 2. Download and install Courier Mod Loader using the instructions on the release page * [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases) From cb0704394416ceffba1c00a5fe4c06330e0389c0 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 7 Oct 2023 17:48:08 -0500 Subject: [PATCH 069/163] drop tomllib --- worlds/messenger/__init__.py | 2 +- worlds/messenger/client_setup.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 64f210047e6a..698a7ae87a15 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -14,7 +14,7 @@ from .client_setup import launch_game components.append( - Component("The Messenger", cli=True, component_type=Type.CLIENT, func=launch_game) + Component("The Messenger", component_type=Type.CLIENT, func=launch_game) ) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 4d9b1c782e08..27568da738f5 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -2,7 +2,6 @@ import os.path import subprocess import sys -import tomllib from tkinter.messagebox import askyesnocancel from typing import Any, Optional from zipfile import ZipFile @@ -79,11 +78,12 @@ def request_data(url: str) -> Any: def update_mod() -> Optional[bool]: """Check if the mod needs to be updated, and update if so""" url = "https://raw.githubusercontent.com/alwaysintreble/TheMessengerRandomizerModAP/archipelago/courier.toml" - latest_version = tomllib.loads(requests.get(url).text).get("version") + remote_data = requests.get(url).text + latest_version = remote_data.splitlines()[1].strip("version = \"") toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") - with open(toml_path, "rb") as f: - installed_version = tomllib.load(f).get("version") + with open(toml_path, "r") as f: + installed_version = f.read().splitlines()[1].strip("version = \"") should_update = tuplize_version(latest_version) > tuplize_version(installed_version) if should_update: From 50394ed8c32b21ca577a645c3262a7e05ca59d35 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 7 Oct 2023 18:05:07 -0500 Subject: [PATCH 070/163] don't uselessly import launcher --- worlds/messenger/client_setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 27568da738f5..939e3d944b11 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -8,7 +8,6 @@ import requests -from Launcher import launch from Utils import messagebox, tuplize_version path: str @@ -131,4 +130,4 @@ def launch_game() -> None: else: if update_mod() is None: return - launch(path) + subprocess.Popen(path) From 24b9c806bf8208ef2f17581231b9e67495f8f914 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 10 Oct 2023 17:30:34 -0500 Subject: [PATCH 071/163] The Messenger: fix missing goal requirement for power seal hunt --- worlds/messenger/rules.py | 9 +++++---- worlds/messenger/test/test_shop_chest.py | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index c9bd9b86253d..90d7a7ce1b88 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -37,7 +37,7 @@ def __init__(self, world: MessengerWorld) -> None: "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), "Glacial Peak": self.has_vertical, "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state), - "Music Box": lambda state: state.has_all(set(NOTES), self.player) and self.has_dart(state), + "Music Box": self.has_dart, } self.location_rules = { @@ -145,9 +145,10 @@ def set_messenger_rules(self) -> None: if region.name == "The Shop": for loc in [location for location in region.locations if isinstance(location, MessengerShopLocation)]: loc.access_rule = loc.can_afford - if self.world.options.goal == Goal.option_power_seal_hunt: - set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), - lambda state: state.has("Shop Chest", self.player)) + add_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), + lambda state: state.has("Shop Chest", self.player) + if self.world.options.goal == Goal.option_power_seal_hunt + else state.has_all(NOTES, self.player)) multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) if multiworld.accessibility[self.player] > MessengerAccessibility.option_locations: diff --git a/worlds/messenger/test/test_shop_chest.py b/worlds/messenger/test/test_shop_chest.py index 058a2004478e..e2dac5d2099c 100644 --- a/worlds/messenger/test/test_shop_chest.py +++ b/worlds/messenger/test/test_shop_chest.py @@ -24,11 +24,13 @@ def test_chest_access(self) -> None: self.assertEqual(self.can_reach_location("Shop Chest"), False) self.assertBeatable(False) - self.collect_all_but(["Power Seal", "Shop Chest", "Rescue Phantom"]) + self.collect_all_but(["Power Seal", "Rope Dart", "Shop Chest", "Rescue Phantom"]) self.assertEqual(self.can_reach_location("Shop Chest"), False) self.assertBeatable(False) self.collect_by_name("Power Seal") self.assertEqual(self.can_reach_location("Shop Chest"), True) + self.assertBeatable(False) + self.collect_by_name("Rope Dart") self.assertBeatable(True) From e9b6fca8c4650d8fcc9e10ad72e2bbfe3f72e19c Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 10 Oct 2023 19:28:50 -0500 Subject: [PATCH 072/163] make hard mode goal harder --- worlds/messenger/rules.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 90d7a7ce1b88..bc5d7f75a91f 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -175,6 +175,7 @@ def __init__(self, world: MessengerWorld) -> None: "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) or self.has_windmill(state) or self.has_dart(state), + "Music Box": self.has_vertical, }) self.location_rules.update({ From 27a14883907524480123647dff04fb7b2a26d277 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 13:59:38 -0500 Subject: [PATCH 073/163] make fire seal a bit more lenient --- worlds/messenger/rules.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index bc5d7f75a91f..8284022d3525 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -86,7 +86,8 @@ def __init__(self, world: MessengerWorld) -> None: "Elemental Skylands Seal - Air": self.has_wingsuit, "Elemental Skylands Seal - Water": lambda state: self.has_dart(state) and state.has("Currents Master", self.player), - "Elemental Skylands Seal - Fire": lambda state: self.has_dart(state) and self.can_destroy_projectiles(state), + "Elemental Skylands Seal - Fire": lambda state: self.has_dart(state) and self.can_destroy_projectiles(state) + and self.is_aerobatic(state), "Earth Mega Shard": self.has_dart, "Water Mega Shard": self.has_dart, # corrupted future From 271398dcb34ab98d9da93bb7a56ffbe71b179a30 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 14:49:52 -0500 Subject: [PATCH 074/163] have limited movement force minimal accessibility --- worlds/messenger/__init__.py | 36 ++++++---------------------- worlds/messenger/rules.py | 35 +++++++++++++++++---------- worlds/messenger/test/test_access.py | 3 ++- worlds/messenger/test/test_logic.py | 7 +++++- 4 files changed, 38 insertions(+), 43 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 593b9d81611f..57b0d3e45c5b 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,14 +1,15 @@ import logging from typing import Any, Dict, List, Optional -from BaseClasses import CollectionState, Item, ItemClassification, Tutorial +from BaseClasses import CollectionState, Item, ItemClassification, MultiWorld, Tutorial +from Options import Accessibility from worlds.AutoWorld import WebWorld, World from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS from .options import Goal, Logic, MessengerOptions, NotesNeeded, PowerSeals from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices -from .subclasses import MessengerItem, MessengerRegion +from .subclasses import MessengerItem, MessengerLocation, MessengerRegion class MessengerWeb(WebWorld): @@ -73,12 +74,6 @@ class MessengerWorld(World): shop_prices: Dict[str, int] figurine_prices: Dict[str, int] _filler_items: List[str] - unreachable_locs: List[MessengerLocation] - - def __init__(self, multiworld: "MultiWorld", player: int): - super().__init__(multiworld, player) - self.unreachable_locs = [] - def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: @@ -88,6 +83,7 @@ def generate_early(self) -> None: if self.options.limited_movement: self.options.shuffle_seals.value = PowerSeals.option_true self.options.logic_level.value = Logic.option_hard + self.options.accessibility.value = Accessibility.option_minimal self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) @@ -108,7 +104,7 @@ def create_items(self) -> None: *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, } and "Time Shard" not in item ] - + if self.options.limited_movement: itempool.append(self.create_item(self.random.choice(main_movement_items))) else: @@ -143,16 +139,7 @@ def create_items(self) -> None: itempool += seals self.multiworld.itempool += itempool - if self.options.limited_movement and not self.options.accessibility == Accessibility.option_minimal: - # hardcoding these until i figure out a better solution - # need to figure out which locations are inaccessible with the missing item, and create filler based on - # that count - if self.options.shuffle_shards: - remaining_fill = 96 - len(self.multiworld.get_filled_locations(self.player)) - else: - remaining_fill = 66 - len(self.multiworld.get_filled_locations(self.player)) - else: - remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) + remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) if remaining_fill < 10: self._filler_items = self.random.choices( list(FILLER)[2:], @@ -170,16 +157,7 @@ def set_rules(self) -> None: elif logic == Logic.option_hard: MessengerHardRules(self).set_messenger_rules() else: - Rules.MessengerOOBRules(self).set_messenger_rules() - - def generate_basic(self) -> None: - if self.options.limited_movement and not self.options.accessibility == Accessibility.option_minimal: - all_state = self.multiworld.get_all_state(False) - reachable_locs = self.multiworld.get_reachable_locations(all_state, self.player) - unreachable_locs = list(set(self.multiworld.get_locations(self.player)) - set(reachable_locs)) - for loc in unreachable_locs: - loc.place_locked_item(self.create_item("Time Shard")) - loc.access_rule = lambda state: True + MessengerOOBRules(self).set_messenger_rules() def fill_slot_data(self) -> Dict[str, Any]: shop_prices = {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()} diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 8284022d3525..d1e3339aee95 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -1,7 +1,7 @@ from typing import Callable, Dict, TYPE_CHECKING from BaseClasses import CollectionState -from worlds.generic.Rules import add_rule, allow_self_locking_items, set_rule +from worlds.generic.Rules import add_rule, allow_self_locking_items from .constants import NOTES, PHOBEKINS from .options import Goal, MessengerAccessibility from .subclasses import MessengerShopLocation @@ -34,7 +34,8 @@ def __init__(self, world: MessengerWorld) -> None: "Underworld": self.has_tabi, "Riviere Turquoise": lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), - "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), + "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) + and self.can_dboost(state), "Glacial Peak": self.has_vertical, "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state), "Music Box": self.has_dart, @@ -182,9 +183,12 @@ def __init__(self, world: MessengerWorld) -> None: self.location_rules.update({ "Howling Grotto Seal - Windy Saws and Balls": self.true, "Searing Crags Seal - Triple Ball Spinner": self.true, - "Searing Crags Seal - Raining Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Searing Crags Seal - Rhythm Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Searing Crags - Power Thistle": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), + "Searing Crags Seal - Raining Rocks": lambda state: self.has_vertical(state) + or self.can_destroy_projectiles(state), + "Searing Crags Seal - Rhythm Rocks": lambda state: self.has_vertical(state) + or self.can_destroy_projectiles(state), + "Searing Crags - Power Thistle": lambda state: self.has_vertical(state) + or self.can_destroy_projectiles(state), "Glacial Peak Seal - Ice Climbers": lambda state: self.has_vertical(state) or self.can_dboost(state), "Glacial Peak Seal - Projectile Spike Pit": self.true, "Glacial Peak Seal - Glacial Air Swag": lambda state: self.has_windmill(state) or self.has_vertical(state), @@ -197,8 +201,10 @@ def __init__(self, world: MessengerWorld) -> None: "Elemental Skylands Seal - Fire": lambda state: (self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state)) and self.can_destroy_projectiles(state), - "Earth Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), - "Water Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Earth Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) + or self.has_windmill(state), + "Water Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) + or self.has_windmill(state), }) self.extra_rules = { @@ -229,7 +235,8 @@ def __init__(self, world: MessengerWorld) -> None: self.region_rules = { "Elemental Skylands": - lambda state: state.has_any({"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player), + lambda state: state.has_any({"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, + self.player), "Music Box": lambda state: state.has_all(set(NOTES), self.player) } @@ -259,13 +266,17 @@ def set_self_locking_items(world: MessengerWorld, player: int) -> None: multiworld = world.multiworld # do the ones for seal shuffle on and off first - allow_self_locking_items(multiworld.get_location("Searing Crags - Key of Strength", player), "Power Thistle") - allow_self_locking_items(multiworld.get_location("Sunken Shrine - Key of Love", player), "Sun Crest", "Moon Crest") - allow_self_locking_items(multiworld.get_location("Corrupted Future - Key of Courage", player), "Demon King Crown") + allow_self_locking_items(multiworld.get_location("Searing Crags - Key of Strength", player), + "Power Thistle") + allow_self_locking_items(multiworld.get_location("Sunken Shrine - Key of Love", player), + "Sun Crest", "Moon Crest") + allow_self_locking_items(multiworld.get_location("Corrupted Future - Key of Courage", player), + "Demon King Crown") # add these locations when seals are shuffled if world.options.shuffle_seals: - allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), "Currents Master") + allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), + "Currents Master") # add these locations when seals and shards aren't shuffled elif not world.options.shuffle_shards: for entrance in multiworld.get_region("Cloud Ruins", player).entrances: diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py index 7a77a9b06695..6f7be73f77af 100644 --- a/worlds/messenger/test/test_access.py +++ b/worlds/messenger/test/test_access.py @@ -169,4 +169,5 @@ def test_self_locking_items(self) -> None: for item_name in location_lock_pairs[loc]: item = self.get_item_by_name(item_name) with self.subTest("Fulfills Accessibility", location=loc, item=item_name): - self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, True)) + self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, + True)) diff --git a/worlds/messenger/test/test_logic.py b/worlds/messenger/test/test_logic.py index 6b425b96058b..fc3e0cc988cb 100644 --- a/worlds/messenger/test/test_logic.py +++ b/worlds/messenger/test/test_logic.py @@ -123,7 +123,12 @@ class LimitedMovementTest(MessengerTestBase): "shuffle_seals": "false", "shuffle_shards": "true", } - + + @property + def run_default_tests(self) -> bool: + # This test base fails reachability tests. Not sure if the core tests should change to support that + return False + def testOptions(self) -> None: """Tests that options were correctly changed.""" world = cast(MessengerWorld, self.multiworld.worlds[self.player]) From f0d921f7fef1f25d79c13df79f2dd21279da867f Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 14:59:46 -0500 Subject: [PATCH 075/163] add an early meditation option --- worlds/messenger/__init__.py | 2 ++ worlds/messenger/options.py | 6 +++++ worlds/messenger/test/test_logic.py | 19 ------------- worlds/messenger/test/test_options.py | 39 +++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 worlds/messenger/test/test_options.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 57b0d3e45c5b..0e4464995aa1 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -85,6 +85,8 @@ def generate_early(self) -> None: self.options.logic_level.value = Logic.option_hard self.options.accessibility.value = Accessibility.option_minimal + self.multiworld.early_items[self.player]["Meditation"] = self.options.early_meditation.value + self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) def create_regions(self) -> None: diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 2f64fc44056f..b5831840eb79 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -43,6 +43,11 @@ class LimitedMovement(Toggle): display_name = "Limited Movement" +class EarlyMed(Toggle): + """Guarantees meditation will be found early""" + display_name = "Early Meditation" + + class Goal(Choice): """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" display_name = "Goal" @@ -145,6 +150,7 @@ class MessengerOptions(PerGameCommonOptions): shuffle_seals: PowerSeals shuffle_shards: MegaShards limited_movement: LimitedMovement + early_meditation: EarlyMed goal: Goal music_box: MusicBox notes_needed: NotesNeeded diff --git a/worlds/messenger/test/test_logic.py b/worlds/messenger/test/test_logic.py index fc3e0cc988cb..911ab71fd4f5 100644 --- a/worlds/messenger/test/test_logic.py +++ b/worlds/messenger/test/test_logic.py @@ -115,22 +115,3 @@ def test_access(self) -> None: with self.subTest("Default unreachables", location=loc): self.assertFalse(self.can_reach_location(loc)) self.assertBeatable(True) - - -class LimitedMovementTest(MessengerTestBase): - options = { - "limited_movement": "true", - "shuffle_seals": "false", - "shuffle_shards": "true", - } - - @property - def run_default_tests(self) -> bool: - # This test base fails reachability tests. Not sure if the core tests should change to support that - return False - - def testOptions(self) -> None: - """Tests that options were correctly changed.""" - world = cast(MessengerWorld, self.multiworld.worlds[self.player]) - self.assertEqual(PowerSeals.option_true, world.options.shuffle_seals) - self.assertEqual(Logic.option_hard, world.options.logic_level) diff --git a/worlds/messenger/test/test_options.py b/worlds/messenger/test/test_options.py new file mode 100644 index 000000000000..237f116a19f5 --- /dev/null +++ b/worlds/messenger/test/test_options.py @@ -0,0 +1,39 @@ +from typing import cast + +from BaseClasses import CollectionState +from Fill import distribute_items_restrictive +from . import MessengerTestBase +from .. import MessengerWorld +from ..options import Logic, PowerSeals + + +class LimitedMovementTest(MessengerTestBase): + options = { + "limited_movement": "true", + "shuffle_seals": "false", + "shuffle_shards": "true", + } + + @property + def run_default_tests(self) -> bool: + # This test base fails reachability tests. Not sure if the core tests should change to support that + return False + + def test_options(self) -> None: + """Tests that options were correctly changed.""" + world = cast(MessengerWorld, self.multiworld.worlds[self.player]) + self.assertEqual(PowerSeals.option_true, world.options.shuffle_seals) + self.assertEqual(Logic.option_hard, world.options.logic_level) + + +class EarlyMeditationTest(MessengerTestBase): + options = { + "early_meditation": "true", + } + + def test_option(self) -> None: + """Checks that Meditation gets placed early""" + distribute_items_restrictive(self.multiworld) + sphere1 = self.multiworld.get_reachable_locations(CollectionState(self.multiworld)) + items = [loc.item.name for loc in sphere1] + self.assertIn("Meditation", items) From aceeb50d290154ec6b047e33f17cbd36fbfa93f6 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 15:13:10 -0500 Subject: [PATCH 076/163] clean up precollected notes tests a bit --- worlds/messenger/test/test_notes.py | 40 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/worlds/messenger/test/test_notes.py b/worlds/messenger/test/test_notes.py index 46cec5f3c819..fdb1cef1dfbe 100644 --- a/worlds/messenger/test/test_notes.py +++ b/worlds/messenger/test/test_notes.py @@ -2,29 +2,19 @@ from ..constants import NOTES -class TwoNoteGoalTest(MessengerTestBase): - options = { - "notes_needed": 2, - } - - def test_precollected_notes(self) -> None: - self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4) - - -class FourNoteGoalTest(MessengerTestBase): - options = { - "notes_needed": 4, - } - - def test_precollected_notes(self) -> None: - self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2) +class PrecollectedNotesTestBase(MessengerTestBase): + starting_notes: int = 0 + @property + def run_default_tests(self) -> bool: + return False -class DefaultGoalTest(MessengerTestBase): def test_precollected_notes(self) -> None: - self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0) + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), self.starting_notes) def test_goal(self) -> None: + if self.__class__ is not PrecollectedNotesTestBase: + return self.assertBeatable(False) self.collect_by_name(NOTES) rope_dart = self.get_item_by_name("Rope Dart") @@ -33,3 +23,17 @@ def test_goal(self) -> None: self.remove(rope_dart) self.collect_by_name("Wingsuit") self.assertBeatable(True) + + +class TwoNoteGoalTest(PrecollectedNotesTestBase): + options = { + "notes_needed": 2, + } + starting_notes = 4 + + +class FourNoteGoalTest(PrecollectedNotesTestBase): + options = { + "notes_needed": 4, + } + starting_notes = 2 From cf119a9892bc4b01670414f99dff3cecd405699b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 15:42:19 -0500 Subject: [PATCH 077/163] add linux support --- worlds/messenger/__init__.py | 5 ++-- worlds/messenger/client_setup.py | 39 ++++++++++++++++---------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index b1559b8a4df7..2ce57772d683 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,12 +1,13 @@ import logging -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional from BaseClasses import CollectionState, Item, ItemClassification, Tutorial +from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components from .client_setup import launch_game from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS -from .options import Goal, Logic, NotesNeeded, PowerSeals +from .options import Goal, Logic, MessengerOptions, NotesNeeded, PowerSeals from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerItem, MessengerRegion diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 939e3d944b11..575b7980105c 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -1,14 +1,18 @@ +import cmd +import io import logging import os.path import subprocess import sys +import urllib.request +from shutil import which from tkinter.messagebox import askyesnocancel from typing import Any, Optional from zipfile import ZipFile import requests -from Utils import messagebox, tuplize_version +from Utils import is_linux, is_windows, messagebox, tuplize_version path: str folder: str @@ -31,22 +35,22 @@ def install_courier() -> Optional[bool]: assets = request_data(courier_url)[0]["assets"] latest_download = assets[-1]["browser_download_url"] - remote_file = requests.get(latest_download) - temp_file = assets[-1]["name"] - with open(temp_file, "wb") as f: - f.write(remote_file.content) - - with ZipFile(temp_file, "r") as data: - data.extractall(folder) - os.remove(temp_file) + with urllib.request.urlopen(latest_download) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + zf.extractall(folder) working_directory = os.getcwd() os.chdir(folder) - installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe")) + if is_linux: + mono_exe = which("mono") + installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) + else: + installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe")) failure = installer.wait() if failure: messagebox("Failure", "Failed to install Courier", True) os.chdir(working_directory) + if courier_installed(): messagebox("Success!", "Courier successfully installed!") return True @@ -101,15 +105,10 @@ def install_mod() -> None: assets = request_data(url)["assets"] release_url = assets[0]["browser_download_url"] - remote_file = requests.get(release_url) - temp_file = assets[0]["name"] - with open(temp_file, "wb") as f: - f.write(remote_file.content) + with urllib.request.urlopen(release_url) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + zf.extractall(folder) - with ZipFile(temp_file, "r") as data: - target = os.path.join(folder, "Mods") - data.extractall(target) - os.remove(temp_file) messagebox("Success!", "Latest mod successfully installed!") @@ -119,6 +118,8 @@ def launch_game() -> None: global path, folder path = MessengerWorld.settings.game_path folder = os.path.dirname(path) + if not (is_linux or is_windows): + return if not courier_installed(): if not install_courier(): return @@ -130,4 +131,4 @@ def launch_game() -> None: else: if update_mod() is None: return - subprocess.Popen(path) + os.startfile("steam://rungameid/764790") From 9c8e08b340b9938fcb948fa330aa7c5cd4362b81 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 16:03:55 -0500 Subject: [PATCH 078/163] add steam deck support --- worlds/messenger/__init__.py | 2 ++ worlds/messenger/client_setup.py | 20 +++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 2ce57772d683..a0f7239e5dfa 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -2,6 +2,7 @@ from typing import Any, ClassVar, Dict, List, Optional from BaseClasses import CollectionState, Item, ItemClassification, Tutorial +from Options import Accessibility from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components @@ -9,6 +10,7 @@ from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS from .options import Goal, Logic, MessengerOptions, NotesNeeded, PowerSeals from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS +from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerItem, MessengerRegion diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 575b7980105c..768e9844d0f1 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -16,7 +16,7 @@ path: str folder: str - +mono_exe = None def courier_installed() -> bool: """Check if Courier is installed""" @@ -42,8 +42,20 @@ def install_courier() -> Optional[bool]: working_directory = os.getcwd() os.chdir(folder) if is_linux: + global mono_exe mono_exe = which("mono") - installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) + if not mono_exe: + # download and use mono kickstart + # this allows steam deck support + mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/main.zip" + target = os.path.join(folder, "monoKickstart") + with urllib.request.urlopen(mono_kick_url) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + os.makedirs(target, exist_ok=True) + zf.extractall(target) + os.startfile(os.path.join(target, "precompiled"), os.path.join(folder, "MiniInstaller.exe")) + else: + installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) else: installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe")) failure = installer.wait() @@ -115,7 +127,7 @@ def install_mod() -> None: def launch_game() -> None: """Check the game installation, then launch it""" from . import MessengerWorld - global path, folder + global path, folder, mono path = MessengerWorld.settings.game_path folder = os.path.dirname(path) if not (is_linux or is_windows): @@ -131,4 +143,6 @@ def launch_game() -> None: else: if update_mod() is None: return + if is_linux and not mono_exe: # don't launch the game if we're on steam deck + return os.startfile("steam://rungameid/764790") From 79d706250a41a3dc2d0bb6557d8f3b72c8b08752 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 16:05:15 -0500 Subject: [PATCH 079/163] await monokickstart --- worlds/messenger/client_setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 768e9844d0f1..059af3584c4c 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -53,7 +53,7 @@ def install_courier() -> Optional[bool]: with ZipFile(io.BytesIO(download.read()), "r") as zf: os.makedirs(target, exist_ok=True) zf.extractall(target) - os.startfile(os.path.join(target, "precompiled"), os.path.join(folder, "MiniInstaller.exe")) + installer = subprocess.Popen([os.path.join(target, "precompiled"), os.path.join(folder, "MiniInstaller.exe")]) else: installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) else: From 7054c2e55b990488d8876c5873d1d067ab098339 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 16:07:16 -0500 Subject: [PATCH 080/163] minor styling cleanup --- worlds/messenger/client_setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 059af3584c4c..0667d82f60b6 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -1,4 +1,3 @@ -import cmd import io import logging import os.path @@ -18,6 +17,7 @@ folder: str mono_exe = None + def courier_installed() -> bool: """Check if Courier is installed""" return os.path.exists(os.path.join(folder, "miniinstaller-log.txt")) @@ -53,7 +53,8 @@ def install_courier() -> Optional[bool]: with ZipFile(io.BytesIO(download.read()), "r") as zf: os.makedirs(target, exist_ok=True) zf.extractall(target) - installer = subprocess.Popen([os.path.join(target, "precompiled"), os.path.join(folder, "MiniInstaller.exe")]) + installer = subprocess.Popen([os.path.join(target, "precompiled"), + os.path.join(folder, "MiniInstaller.exe")]) else: installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) else: From 070df134dd13a3202ff7d62d868222525d1a638d Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 12 Oct 2023 17:00:06 -0500 Subject: [PATCH 081/163] more minor styling cleanup --- worlds/messenger/client_setup.py | 223 ++++++++++++++----------------- 1 file changed, 102 insertions(+), 121 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 0667d82f60b6..f6e0b1d740d1 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -2,148 +2,129 @@ import logging import os.path import subprocess -import sys import urllib.request from shutil import which from tkinter.messagebox import askyesnocancel -from typing import Any, Optional +from typing import Any from zipfile import ZipFile import requests from Utils import is_linux, is_windows, messagebox, tuplize_version -path: str -folder: str -mono_exe = None - - -def courier_installed() -> bool: - """Check if Courier is installed""" - return os.path.exists(os.path.join(folder, "miniinstaller-log.txt")) - - -def install_courier() -> Optional[bool]: - """Installs latest version of Courier""" - should_install = askyesnocancel("Install Courier", - "No Courier installation detected. Would you like to install now?") - if not should_install: - return should_install - - # can't use latest since courier uses pre-release tags - courier_url = "https://api.github.com/repos/Brokemia/Courier/releases" - assets = request_data(courier_url)[0]["assets"] - latest_download = assets[-1]["browser_download_url"] - - with urllib.request.urlopen(latest_download) as download: - with ZipFile(io.BytesIO(download.read()), "r") as zf: - zf.extractall(folder) - - working_directory = os.getcwd() - os.chdir(folder) - if is_linux: - global mono_exe - mono_exe = which("mono") - if not mono_exe: - # download and use mono kickstart - # this allows steam deck support - mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/main.zip" - target = os.path.join(folder, "monoKickstart") - with urllib.request.urlopen(mono_kick_url) as download: - with ZipFile(io.BytesIO(download.read()), "r") as zf: - os.makedirs(target, exist_ok=True) - zf.extractall(target) - installer = subprocess.Popen([os.path.join(target, "precompiled"), - os.path.join(folder, "MiniInstaller.exe")]) - else: - installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) - else: - installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe")) - failure = installer.wait() - if failure: - messagebox("Failure", "Failed to install Courier", True) - os.chdir(working_directory) - - if courier_installed(): - messagebox("Success!", "Courier successfully installed!") - return True - messagebox("Failure", "Failed to install Courier", True) - return False - - -def mod_installed() -> bool: - """Check if the mod is installed""" - return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) - - -def request_data(url: str) -> Any: - logging.info(f"requesting {url}") - response = requests.get(url) - if response.status_code == 200: # success - try: - data = response.json() - except requests.exceptions.JSONDecodeError: - logging.error(f"Unable to fetch version update data. (status code {response.status_code})") - sys.exit(1) - else: - logging.error(f"Unable to fetch version update data. (status code {response.status_code})") - sys.exit(1) - return data - - -def update_mod() -> Optional[bool]: - """Check if the mod needs to be updated, and update if so""" - url = "https://raw.githubusercontent.com/alwaysintreble/TheMessengerRandomizerModAP/archipelago/courier.toml" - remote_data = requests.get(url).text - latest_version = remote_data.splitlines()[1].strip("version = \"") - - toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") - with open(toml_path, "r") as f: - installed_version = f.read().splitlines()[1].strip("version = \"") - - should_update = tuplize_version(latest_version) > tuplize_version(installed_version) - if should_update: - should_update = askyesnocancel("Update Mod", - "Old mod version detected. Would you like to update now?") - - if not should_update: - return should_update - - install_mod() - - -def install_mod() -> None: - """Installs latest version of the mod""" - url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" - assets = request_data(url)["assets"] - release_url = assets[0]["browser_download_url"] - - with urllib.request.urlopen(release_url) as download: - with ZipFile(io.BytesIO(download.read()), "r") as zf: - zf.extractall(folder) - - messagebox("Success!", "Latest mod successfully installed!") - def launch_game() -> None: """Check the game installation, then launch it""" - from . import MessengerWorld - global path, folder, mono - path = MessengerWorld.settings.game_path - folder = os.path.dirname(path) if not (is_linux or is_windows): return + + def courier_installed() -> bool: + """Check if Courier is installed""" + return os.path.exists(os.path.join(folder, "miniinstaller-log.txt")) + + def install_courier() -> None: + """Installs latest version of Courier""" + + # can't use latest since courier uses pre-release tags + courier_url = "https://api.github.com/repos/Brokemia/Courier/releases" + latest_download = request_data(courier_url)[0]["assets"][-1]["browser_download_url"] + + with urllib.request.urlopen(latest_download) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + zf.extractall(folder) + + working_directory = os.getcwd() + os.chdir(folder) + if is_linux: + mono_exe = which("mono") + if not mono_exe: + # download and use mono kickstart + # this allows steam deck support + mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/main.zip" + target = os.path.join(folder, "monoKickstart") + with urllib.request.urlopen(mono_kick_url) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + os.makedirs(target, exist_ok=True) + zf.extractall(target) + installer = subprocess.Popen([os.path.join(target, "precompiled"), + os.path.join(folder, "MiniInstaller.exe")], shell=False) + else: + installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) + else: + installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe"), shell=False) + failure = installer.wait() + if failure: + messagebox("Failure", "Failed to install Courier", True) + os.chdir(working_directory) + raise RuntimeError("Failed to install Courier") + os.chdir(working_directory) + + if courier_installed(): + messagebox("Success!", "Courier successfully installed!") + messagebox("Failure", "Failed to install Courier", True) + raise RuntimeError("Failed to install Courier") + + def mod_installed() -> bool: + """Check if the mod is installed""" + return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) + + def request_data(url: str) -> Any: + """Fetches json response from given url""" + logging.info(f"requesting {url}") + response = requests.get(url) + if response.status_code == 200: # success + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") + else: + raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") + return data + + def available_mod_update() -> bool: + """Check if there's an available update""" + url = "https://raw.githubusercontent.com/alwaysintreble/TheMessengerRandomizerModAP/archipelago/courier.toml" + remote_data = requests.get(url).text + latest_version = remote_data.splitlines()[1].strip("version = \"") + + toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") + with open(toml_path, "r") as f: + installed_version = f.read().splitlines()[1].strip("version = \"") + + return tuplize_version(latest_version) > tuplize_version(installed_version) + + def install_mod() -> None: + """Installs latest version of the mod""" + url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" + assets = request_data(url)["assets"] + release_url = assets[0]["browser_download_url"] + + with urllib.request.urlopen(release_url) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + zf.extractall(folder) + + messagebox("Success!", "Latest mod successfully installed!") + + from . import MessengerWorld + folder = os.path.dirname(MessengerWorld.settings.game_path) if not courier_installed(): - if not install_courier(): + should_install = askyesnocancel("Install Courier", + "No Courier installation detected. Would you like to install now?") + if not should_install: return + install_courier() if not mod_installed(): should_install = askyesnocancel("Install Mod", "No randomizer mod detected. Would you like to install now?") if not should_install: return + install_mod() else: - if update_mod() is None: - return - if is_linux and not mono_exe: # don't launch the game if we're on steam deck + if available_mod_update(): + should_update = askyesnocancel("Update Mod", + "Old mod version detected. Would you like to update now?") + if should_update: + install_mod() + if is_linux and not which("mono"): # don't launch the game if we're on steam deck return os.startfile("steam://rungameid/764790") From ab4856cb3442eb1caea79b5447e00a5174b89a7c Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sun, 19 Nov 2023 15:25:33 -0800 Subject: [PATCH 082/163] Initial implementation of Generic ER --- BaseClasses.py | 41 +++++- EntranceRando.py | 329 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 EntranceRando.py diff --git a/BaseClasses.py b/BaseClasses.py index a70dd70a9238..682876c351b3 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -17,6 +17,7 @@ import NetUtils import Options import Utils +from EntranceRando import ERType, ERPlacementState class Group(TypedDict, total=False): @@ -766,20 +767,26 @@ def remove(self, item: Item): class Entrance: + access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False player: int name: str parent_region: Optional[Region] connected_region: Optional[Region] = None + er_group: str + er_type: ERType # LttP specific, TODO: should make a LttPEntrance addresses = None target = None - def __init__(self, player: int, name: str = '', parent: Region = None): + def __init__(self, player: int, name: str = '', parent: Region = None, + er_group: str = 'Default', er_type: ERType = ERType.ONE_WAY): self.name = name self.parent_region = parent self.player = player + self.er_group = er_group + self.er_type = er_type def can_reach(self, state: CollectionState) -> bool: if self.parent_region.can_reach(state) and self.access_rule(state): @@ -795,6 +802,28 @@ def connect(self, region: Region, addresses: Any = None, target: Any = None) -> self.addresses = addresses region.entrances.append(self) + def is_valid_source_transition(self, state: ERPlacementState) -> bool: + """ + Determines whether this is a valid source transition, that is, whether the entrance + randomizer is allowed to pair it to place any other regions. By default, this is the + same as a reachability check, but can be modified by Entrance implementations to add + other restrictions based on the placement state. + + :param state: The current (partial) state of the ongoing entrance randomization + """ + return self.can_reach(state.collection_state) + + def can_connect_to(self, other: Entrance, state: ERPlacementState) -> bool: + """ + Determines whether a given Entrance is a valid target transition, that is, whether + the entrance randomizer is allowed to pair this Entrance to that Entrance. + + :param other: The proposed Entrance to connect to + :param state: The current (partial) state of the ongoing entrance randomization + :param group_one_ways: Whether to enforce that one-ways are paired together. + """ + return self.er_type == other.er_type + def __repr__(self): return self.__str__() @@ -943,6 +972,16 @@ def create_exit(self, name: str) -> Entrance: self.exits.append(exit_) return exit_ + def create_er_entrance(self, name: str) -> Entrance: + """ + Creates and returns an Entrance object as an entrance to this region + + :param name: name of the Entrance being created + """ + entrance = self.entrance_type(self.player, name) + entrance.connect(self) + return entrance + def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None: """ diff --git a/EntranceRando.py b/EntranceRando.py new file mode 100644 index 000000000000..2d0d4cc63ce8 --- /dev/null +++ b/EntranceRando.py @@ -0,0 +1,329 @@ +import functools +import queue +import random +from dataclasses import dataclass +from enum import IntEnum +from typing import Set, Tuple, List, Dict, Iterable, Callable, Union + +from BaseClasses import Region, Entrance, CollectionState +from worlds.AutoWorld import World + + +class ERType(IntEnum): + ONE_WAY = 1 + TWO_WAY = 2 + + +class EntranceLookup: + class GroupLookup: + _lookup: Dict[str, List[Entrance]] + + def __init__(self): + self._lookup = {} + + def __bool__(self): + return bool(self._lookup) + + def add(self, entrance: Entrance) -> None: + group = self._lookup.setdefault(entrance.er_group, []) + group.append(entrance) + + def remove(self, entrance: Entrance) -> None: + group = self._lookup.setdefault(entrance.er_group) + group.remove(entrance) + + def __getitem__(self, item: str) -> List[Entrance]: + return self._lookup.get(item, []) + + rng: random.Random + dead_ends: GroupLookup + others: GroupLookup + + def __init__(self, rng: random.Random): + self.rng = rng + self.dead_ends = EntranceLookup.GroupLookup() + self.others = EntranceLookup.GroupLookup() + + @staticmethod + @functools.cache + def _is_dead_end(entrance: Entrance): + """ + Checks whether a entrance is an unconditional dead end, that is, no matter what you have, + it will never lead to new randomizable exits. + """ + + # obviously if this is an unpaired exit, then leads to unpaired exits! + if not entrance.connected_region: + return False + # if the connected region has no exits, it's a dead end. otherwise its exits must all be dead ends. + return not entrance.connected_region.exits or all(EntranceLookup._is_dead_end(exit) + for exit in entrance.connected_region.exits + if exit.name != entrance.name) + + def add(self, entrance: Entrance) -> None: + lookup = self.dead_ends if self._is_dead_end(entrance) else self.others + lookup.add(entrance) + + def remove(self, entrance: Entrance) -> None: + lookup = self.dead_ends if self._is_dead_end(entrance) else self.others + lookup.remove(entrance) + + def get_targets( + self, + groups: Iterable[str], + dead_end: bool, + preserve_group_order: bool + ) -> Iterable[Entrance]: + + lookup = self.dead_ends if dead_end else self.others + if preserve_group_order: + for group in groups: + self.rng.shuffle(lookup[group]) + ret = [entrance for group in groups for entrance in lookup[group]] + else: + ret = [entrance for group in groups for entrance in lookup[group]] + self.rng.shuffle(ret) + return ret + + +class ERPlacementState: + # candidate exits to try and place right now! + _placeable_exits: Set[Entrance] + # exits that are gated by some unmet requirement (either they are not valid source transitions yet + # or they are static connections with unmet logic restrictions) + _pending_exits: Set[Entrance] + + placed_regions: Set[Region] + placements: List[Entrance] + pairings: List[Tuple[str, str]] + world: World + collection_state: CollectionState + coupled: bool + + def __init__(self, world: World, coupled: bool): + self._placeable_exits = set() + self._pending_exits = set() + + self.placed_regions = set() + self.placements = [] + self.pairings = [] + self.world = world + self.collection_state = world.multiworld.get_all_state(True) + self.coupled = coupled + + def has_placeable_exits(self) -> bool: + return bool(self._placeable_exits) + + def place(self, start: Union[Region, Entrance]) -> None: + """ + Traverses a region's connected exits to find any newly available randomizable + exits which stem from that region. + + :param start: The starting region or entrance to traverse from. + """ + + q = queue.Queue[Region]() + starting_entrance_name = None + if isinstance(start, Entrance): + starting_entrance_name = start.name + q.put(start.parent_region) + else: + q.put(start) + + while q: + region = q.get() + if region in self.placed_regions: + continue + self.placed_regions.add(region) + # collect events + local_locations = self.world.multiworld.get_locations(self.world.player) + self.collection_state.sweep_for_events(locations=local_locations) + # traverse exits + for exit in region.exits: + # if the exit is unconnected, it's a candidate for randomization + if not exit.connected_region: + # in coupled, the reverse transition will be handled specially; + # don't add it as a candidate. in uncoupled it's fair game. + if not self.coupled or exit.name != starting_entrance_name: + if exit.is_valid_source_transition(self): + self._placeable_exits.add(exit) + else: + self._pending_exits.add(exit) + elif exit.connected_region not in self.placed_regions: + # traverse unseen static connections + if exit.can_reach(self.collection_state): + q.put(exit) + else: + self._pending_exits.add(exit) + + def sweep_pending_exits(self) -> None: + """ + Checks if any exits which previously had unmet restrictions now have those restrictions met, + and marks them for placement or places them depending on whether they are randomized or not. + """ + no_longer_pending_exits = [] + for exit in self._pending_exits: + if exit.connected_region and exit.can_reach(self.collection_state): + # this is an unrandomized entrance, so place it and propagate + self.place(exit.connected_region) + no_longer_pending_exits.append(exit) + elif not exit.connected_region and exit.is_valid_source_transition(self): + # this is randomized so mark it eligible for placement + self._placeable_exits.add(exit) + no_longer_pending_exits.append(exit) + self._pending_exits.difference_update(no_longer_pending_exits) + + def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: + target_region = target_entrance.connected_region + + target_region.entrances.remove(target_entrance) + source_exit.connect(target_region) + self.placements.append(source_exit) + self.pairings.append((source_exit.name, target_entrance.name)) + + def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[Entrance]: + """ + Connects a source exit to a target entrance in the graph, accounting for coupling + + :returns: The dummy entrance(s) which were removed from the graph + """ + source_region = source_exit.parent_region + target_region = target_entrance.connected_region + + self._connect_one_way(source_exit, target_entrance) + # if we're doing coupled randomization place the reverse transition as well. + if self.coupled and source_exit.er_type == ERType.TWO_WAY: + # todo - better exceptions here + for reverse_entrance in source_region.entrances: + if reverse_entrance.name == source_exit.name: + break + else: + raise Exception(f'Two way exit {source_exit.name} had no corresponding entrance in ' + f'{source_exit.parent_region.name}') + for reverse_exit in target_region.exits: + if reverse_exit.name == target_entrance.name: + break + else: + raise Exception(f'Two way entrance {target_entrance.name} had no corresponding exit in ' + f'{target_region.name}') + self._connect_one_way(reverse_exit, reverse_entrance) + return [target_entrance, reverse_entrance] + return [target_entrance] + + +def randomize_entrances( + world: World, + rng: random.Random, + regions: Iterable[Region], + coupled: bool, + get_target_groups: Callable[[str], List[str]], + preserve_group_order: bool = False + ) -> ERPlacementState: + """ + Randomizes Entrances for a single world in the multiworld. This should usually be + called in pre_fill or possibly set_rules if you know what you're doing. + + Preconditions: + 1. All of your Regions and all of their exits have been created. + 2. Placeholder entrances have been created as the targets of randomization + (each exit will be randomly paired to an entrance). + 3. Your Menu region is connected to your starting region + 4. All the region connections you don't want to randomize are connected; usually this + is connecting regions within a "scene" but may also include plando'd transitions. + 5. Access rules are set on all relevant region exits + 6. All event items and locations have been placed with access rules applied. + 7. All non-event items have been added to the item pool. + + Postconditions: + 1. All randomizable Entrances will be connected + 2. All placeholder entrances to regions will have been removed. + """ + state = ERPlacementState(world, coupled) + # exits which had no candidate exits found in the non-dead-end stage. + # they will never be placeable until we start placing dead ends so + # hold them here and stop trying. + unplaceable_exits: List[Entrance] = [] + + entrance_lookup = EntranceLookup(rng) + + for region in regions: + for entrance in region.entrances: + if not entrance.parent_region: + entrance_lookup.add(entrance) + + # place the menu region and connected start region(s) + state.place(world.multiworld.get_region('Menu', world.player)) + + while state.has_placeable_exits() and entrance_lookup.others: + # todo - this access to placeable_exits is ugly + # todo - this should iterate placeable exits instead of immediately + # giving up; can_connect_to may be stateful + # todo - this doesn't prioritize placing new rooms like the original did; + # that's problematic because early loops would lead to failures + # this is needed to reduce bias; otherwise newer exits are prioritized + rng.shuffle(state._placeable_exits) + source_exit = state._placeable_exits.pop() + + target_groups = get_target_groups(source_exit.er_group) + # anything can connect to the default group - if people don't like it the fix is to + # assign a non-default group + target_groups.append('Default') + for target_entrance in entrance_lookup.get_targets(target_groups, False, preserve_group_order): + if source_exit.can_connect_to(target_entrance, state): + # we found a valid, connectable target entrance. We'll connect it in a moment + break + else: + # there were no valid non-dead-end targets for this source, so give up on it for now + unplaceable_exits.append(source_exit) + continue + + # place the new pairing + state.place(target_entrance) + removed_entrances = state.connect(source_exit, target_entrance) + state.sweep_pending_exits() + # remove paired entrances from the lookup so they don't get re-randomized + for entrance in removed_entrances: + entrance_lookup.remove(entrance) + + if entrance_lookup.others: + # this is generally an unsalvagable failure, we would need to implement swap earlier in the process + # to prevent it. A stateful can_connect_to implementation may make this recoverable in some worlds as well. + # why? there are no placeable exits, which means none of them have valid targets, and conversely + # none of the existing targets can pair to the existing sources. Since dead ends will never add new sources + # this means the current targets can never be paired (in most cases) + # todo - investigate ways to prevent this case + raise Exception("Unable to place all non-dead-end entrances with available source exits") + + # anything we couldn't place before might be placeable now + state._placeable_exits.union(unplaceable_exits) + unplaceable_exits.clear() + + # repeat the above but try to place dead ends + while state.has_placeable_exits() and entrance_lookup.others: + rng.shuffle(state._placeable_exits) + source_exit = state._placeable_exits.pop() + + target_groups = get_target_groups(source_exit.er_group) + target_groups.append('Default') + for target_entrance in entrance_lookup.get_targets(target_groups, True, preserve_group_order): + if source_exit.can_connect_to(target_entrance, state): + # we found a valid, connectable target entrance. We'll connect it in a moment + break + else: + # there were no valid dead-end targets for this source, so give up + # todo - similar to above we should try and prevent this state. + # also it can_connect_to may be stateful. + raise Exception("Unable to place all dead-end entrances with available source exits") + + # place the new pairing + state.place(target_entrance) + removed_entrances = state.connect(source_exit, target_entrance) + state.sweep_pending_exits() + # remove paired entrances from the lookup so they don't get re-randomized + for entrance in removed_entrances: + entrance_lookup.remove(entrance) + + if state.has_placeable_exits(): + raise Exception("There are more exits than entrances") + + return state From 7be8ce5ade01da1f726cc4f6445cf0f7f91a4d33 Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sun, 19 Nov 2023 20:14:08 -0800 Subject: [PATCH 083/163] Move ERType to Entrance.Type, fix typing imports --- BaseClasses.py | 11 ++++++++--- EntranceRando.py | 10 ++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 682876c351b3..71d728fd8c7b 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -17,7 +17,9 @@ import NetUtils import Options import Utils -from EntranceRando import ERType, ERPlacementState + +if typing.TYPE_CHECKING: + from EntranceRando import ERPlacementState class Group(TypedDict, total=False): @@ -767,6 +769,9 @@ def remove(self, item: Item): class Entrance: + class Type(IntEnum): + ONE_WAY = 1 + TWO_WAY = 2 access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False @@ -775,13 +780,13 @@ class Entrance: parent_region: Optional[Region] connected_region: Optional[Region] = None er_group: str - er_type: ERType + er_type: Type # LttP specific, TODO: should make a LttPEntrance addresses = None target = None def __init__(self, player: int, name: str = '', parent: Region = None, - er_group: str = 'Default', er_type: ERType = ERType.ONE_WAY): + er_group: str = 'Default', er_type: Type = Type.ONE_WAY): self.name = name self.parent_region = parent self.player = player diff --git a/EntranceRando.py b/EntranceRando.py index 2d0d4cc63ce8..a3386f9b63b8 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -1,19 +1,12 @@ import functools import queue import random -from dataclasses import dataclass -from enum import IntEnum from typing import Set, Tuple, List, Dict, Iterable, Callable, Union from BaseClasses import Region, Entrance, CollectionState from worlds.AutoWorld import World -class ERType(IntEnum): - ONE_WAY = 1 - TWO_WAY = 2 - - class EntranceLookup: class GroupLookup: _lookup: Dict[str, List[Entrance]] @@ -44,6 +37,7 @@ def __init__(self, rng: random.Random): self.dead_ends = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup() + # todo - investigate whether this might leak memory (holds references to Entrances)? @staticmethod @functools.cache def _is_dead_end(entrance: Entrance): @@ -192,7 +186,7 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[ self._connect_one_way(source_exit, target_entrance) # if we're doing coupled randomization place the reverse transition as well. - if self.coupled and source_exit.er_type == ERType.TWO_WAY: + if self.coupled and source_exit.er_type == Entrance.Type.TWO_WAY: # todo - better exceptions here for reverse_entrance in source_region.entrances: if reverse_entrance.name == source_exit.name: From 0507134301b3436149e2cdd7716ee9422c0a483d Mon Sep 17 00:00:00 2001 From: qwint Date: Mon, 20 Nov 2023 02:28:16 -0600 Subject: [PATCH 084/163] updates based on testing (read: flailing) --- BaseClasses.py | 23 ++++++++++++----------- EntranceRando.py | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 71d728fd8c7b..f6b29e366080 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -654,17 +654,18 @@ def update_reachable_regions(self, player: int): if new_region in rrp: bc.remove(connection) elif connection.can_reach(self): - assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - rrp.add(new_region) - bc.remove(connection) - bc.update(new_region.exits) - queue.extend(new_region.exits) - self.path[new_region] = (new_region.name, self.path.get(connection, None)) - - # Retry connections if the new region can unblock them - for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): - if new_entrance in bc and new_entrance not in queue: - queue.append(new_entrance) + if new_region: + # assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" + rrp.add(new_region) + bc.remove(connection) + bc.update(new_region.exits) + queue.extend(new_region.exits) + self.path[new_region] = (new_region.name, self.path.get(connection, None)) + + # Retry connections if the new region can unblock them + for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): + if new_entrance in bc and new_entrance not in queue: + queue.append(new_entrance) def copy(self) -> CollectionState: ret = CollectionState(self.multiworld) diff --git a/EntranceRando.py b/EntranceRando.py index a3386f9b63b8..fcd89c99c533 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -40,17 +40,21 @@ def __init__(self, rng: random.Random): # todo - investigate whether this might leak memory (holds references to Entrances)? @staticmethod @functools.cache - def _is_dead_end(entrance: Entrance): + def _is_dead_end(entrance: Entrance, visited_regions=""): """ Checks whether a entrance is an unconditional dead end, that is, no matter what you have, it will never lead to new randomizable exits. """ - # obviously if this is an unpaired exit, then leads to unpaired exits! if not entrance.connected_region: return False + for region in visited_regions.split("::"): + if region == entrance.connected_region.name: + return True + else: + visited_regions += "::" + entrance.connected_region.name # if the connected region has no exits, it's a dead end. otherwise its exits must all be dead ends. - return not entrance.connected_region.exits or all(EntranceLookup._is_dead_end(exit) + return not entrance.connected_region.exits or all(EntranceLookup._is_dead_end(exit, visited_regions) for exit in entrance.connected_region.exits if exit.name != entrance.name) @@ -120,11 +124,11 @@ def place(self, start: Union[Region, Entrance]) -> None: starting_entrance_name = None if isinstance(start, Entrance): starting_entrance_name = start.name - q.put(start.parent_region) + q.put(start.connected_region) else: q.put(start) - while q: + while not q.empty(): region = q.get() if region in self.placed_regions: continue @@ -146,7 +150,7 @@ def place(self, start: Union[Region, Entrance]) -> None: elif exit.connected_region not in self.placed_regions: # traverse unseen static connections if exit.can_reach(self.collection_state): - q.put(exit) + q.put(exit.connected_region) else: self._pending_exits.add(exit) @@ -255,7 +259,7 @@ def randomize_entrances( # todo - this doesn't prioritize placing new rooms like the original did; # that's problematic because early loops would lead to failures # this is needed to reduce bias; otherwise newer exits are prioritized - rng.shuffle(state._placeable_exits) + # rng.shuffle(state._placeable_exits) source_exit = state._placeable_exits.pop() target_groups = get_target_groups(source_exit.er_group) @@ -286,6 +290,7 @@ def randomize_entrances( # none of the existing targets can pair to the existing sources. Since dead ends will never add new sources # this means the current targets can never be paired (in most cases) # todo - investigate ways to prevent this case + return state raise Exception("Unable to place all non-dead-end entrances with available source exits") # anything we couldn't place before might be placeable now From 907cea6864e448db7b6e7e100adc51b88e2de139 Mon Sep 17 00:00:00 2001 From: qwint Date: Mon, 20 Nov 2023 21:06:05 -0600 Subject: [PATCH 085/163] Updates from feedback --- BaseClasses.py | 37 ++++++++++++++++++++----------------- EntranceRando.py | 15 +++++---------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index f6b29e366080..5d6e9ade371f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -634,7 +634,7 @@ def __init__(self, parent: MultiWorld): for item in items: self.collect(item, True) - def update_reachable_regions(self, player: int): + def update_reachable_regions(self, player: int, allow_partial_entrances: bool = False): self.stale[player] = False rrp = self.reachable_regions[player] bc = self.blocked_connections[player] @@ -654,18 +654,21 @@ def update_reachable_regions(self, player: int): if new_region in rrp: bc.remove(connection) elif connection.can_reach(self): - if new_region: - # assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - rrp.add(new_region) - bc.remove(connection) - bc.update(new_region.exits) - queue.extend(new_region.exits) - self.path[new_region] = (new_region.name, self.path.get(connection, None)) - - # Retry connections if the new region can unblock them - for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): - if new_entrance in bc and new_entrance not in queue: - queue.append(new_entrance) + if not allow_partial_entrances: + assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" + else: + if not new_region: + break + rrp.add(new_region) + bc.remove(connection) + bc.update(new_region.exits) + queue.extend(new_region.exits) + self.path[new_region] = (new_region.name, self.path.get(connection, None)) + + # Retry connections if the new region can unblock them + for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): + if new_entrance in bc and new_entrance not in queue: + queue.append(new_entrance) def copy(self) -> CollectionState: ret = CollectionState(self.multiworld) @@ -794,8 +797,8 @@ def __init__(self, player: int, name: str = '', parent: Region = None, self.er_group = er_group self.er_type = er_type - def can_reach(self, state: CollectionState) -> bool: - if self.parent_region.can_reach(state) and self.access_rule(state): + def can_reach(self, state: CollectionState, allow_partial_entrances: bool = False) -> bool: + if self.parent_region.can_reach(state, allow_partial_entrances) and self.access_rule(state): if not self.hide_path and not self in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) return True @@ -925,9 +928,9 @@ def set_exits(self, new): exits = property(get_exits, set_exits) - def can_reach(self, state: CollectionState) -> bool: + def can_reach(self, state: CollectionState, allow_partial_entrances: bool = False) -> bool: if state.stale[self.player]: - state.update_reachable_regions(self.player) + state.update_reachable_regions(self.player, allow_partial_entrances) return self in state.reachable_regions[self.player] @property diff --git a/EntranceRando.py b/EntranceRando.py index fcd89c99c533..611851af4b05 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -40,7 +40,7 @@ def __init__(self, rng: random.Random): # todo - investigate whether this might leak memory (holds references to Entrances)? @staticmethod @functools.cache - def _is_dead_end(entrance: Entrance, visited_regions=""): + def _is_dead_end(entrance: Entrance): """ Checks whether a entrance is an unconditional dead end, that is, no matter what you have, it will never lead to new randomizable exits. @@ -48,13 +48,8 @@ def _is_dead_end(entrance: Entrance, visited_regions=""): # obviously if this is an unpaired exit, then leads to unpaired exits! if not entrance.connected_region: return False - for region in visited_regions.split("::"): - if region == entrance.connected_region.name: - return True - else: - visited_regions += "::" + entrance.connected_region.name # if the connected region has no exits, it's a dead end. otherwise its exits must all be dead ends. - return not entrance.connected_region.exits or all(EntranceLookup._is_dead_end(exit, visited_regions) + return not entrance.connected_region.exits or all(EntranceLookup._is_dead_end(exit) for exit in entrance.connected_region.exits if exit.name != entrance.name) @@ -149,7 +144,7 @@ def place(self, start: Union[Region, Entrance]) -> None: self._pending_exits.add(exit) elif exit.connected_region not in self.placed_regions: # traverse unseen static connections - if exit.can_reach(self.collection_state): + if exit.can_reach(self.collection_state, True): q.put(exit.connected_region) else: self._pending_exits.add(exit) @@ -161,7 +156,7 @@ def sweep_pending_exits(self) -> None: """ no_longer_pending_exits = [] for exit in self._pending_exits: - if exit.connected_region and exit.can_reach(self.collection_state): + if exit.connected_region and exit.can_reach(self.collection_state, True): # this is an unrandomized entrance, so place it and propagate self.place(exit.connected_region) no_longer_pending_exits.append(exit) @@ -290,7 +285,7 @@ def randomize_entrances( # none of the existing targets can pair to the existing sources. Since dead ends will never add new sources # this means the current targets can never be paired (in most cases) # todo - investigate ways to prevent this case - return state + return state # this short circuts the exception for testing purposes in order to see how far ER got. raise Exception("Unable to place all non-dead-end entrances with available source exits") # anything we couldn't place before might be placeable now From ec7fdf74b22079514245d17a66528da169d04930 Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Fri, 24 Nov 2023 21:37:36 -0800 Subject: [PATCH 086/163] Various bug fixes in ERCollectionState --- BaseClasses.py | 21 ++++---- EntranceRando.py | 124 +++++++++++++++++++++++++++++++---------------- 2 files changed, 92 insertions(+), 53 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 5d6e9ade371f..3927e32caebc 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -618,6 +618,7 @@ class CollectionState(): stale: Dict[int, bool] additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] + allow_partial_entrances: bool = False def __init__(self, parent: MultiWorld): self.prog_items = {player: Counter() for player in parent.player_ids} @@ -634,7 +635,7 @@ def __init__(self, parent: MultiWorld): for item in items: self.collect(item, True) - def update_reachable_regions(self, player: int, allow_partial_entrances: bool = False): + def update_reachable_regions(self, player: int): self.stale[player] = False rrp = self.reachable_regions[player] bc = self.blocked_connections[player] @@ -654,11 +655,10 @@ def update_reachable_regions(self, player: int, allow_partial_entrances: bool = if new_region in rrp: bc.remove(connection) elif connection.can_reach(self): - if not allow_partial_entrances: + if not self.allow_partial_entrances: assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - else: - if not new_region: - break + elif not new_region: + continue rrp.add(new_region) bc.remove(connection) bc.update(new_region.exits) @@ -797,8 +797,8 @@ def __init__(self, player: int, name: str = '', parent: Region = None, self.er_group = er_group self.er_type = er_type - def can_reach(self, state: CollectionState, allow_partial_entrances: bool = False) -> bool: - if self.parent_region.can_reach(state, allow_partial_entrances) and self.access_rule(state): + def can_reach(self, state: CollectionState) -> bool: + if self.parent_region.can_reach(state) and self.access_rule(state): if not self.hide_path and not self in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) return True @@ -831,7 +831,8 @@ def can_connect_to(self, other: Entrance, state: ERPlacementState) -> bool: :param state: The current (partial) state of the ongoing entrance randomization :param group_one_ways: Whether to enforce that one-ways are paired together. """ - return self.er_type == other.er_type + # todo - consider allowing self-loops. currently they cause problems in coupled + return self.er_type == other.er_type and (not state.coupled or self.name != other.name) def __repr__(self): return self.__str__() @@ -928,9 +929,9 @@ def set_exits(self, new): exits = property(get_exits, set_exits) - def can_reach(self, state: CollectionState, allow_partial_entrances: bool = False) -> bool: + def can_reach(self, state: CollectionState) -> bool: if state.stale[self.player]: - state.update_reachable_regions(self.player, allow_partial_entrances) + state.update_reachable_regions(self.player) return self in state.reachable_regions[self.player] @property diff --git a/EntranceRando.py b/EntranceRando.py index 611851af4b05..a17ce934d630 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -1,7 +1,7 @@ import functools import queue import random -from typing import Set, Tuple, List, Dict, Iterable, Callable, Union +from typing import Set, Tuple, List, Dict, Iterable, Callable, Union, Optional from BaseClasses import Region, Entrance, CollectionState from worlds.AutoWorld import World @@ -22,8 +22,10 @@ def add(self, entrance: Entrance) -> None: group.append(entrance) def remove(self, entrance: Entrance) -> None: - group = self._lookup.setdefault(entrance.er_group) + group = self._lookup.get(entrance.er_group, []) group.remove(entrance) + if not group: + del self._lookup[entrance.er_group] def __getitem__(self, item: str) -> List[Entrance]: return self._lookup.get(item, []) @@ -31,34 +33,50 @@ def __getitem__(self, item: str) -> List[Entrance]: rng: random.Random dead_ends: GroupLookup others: GroupLookup + _leads_to_exits_cache: Dict[Entrance, bool] def __init__(self, rng: random.Random): self.rng = rng self.dead_ends = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup() + self._leads_to_exits_cache = {} - # todo - investigate whether this might leak memory (holds references to Entrances)? - @staticmethod - @functools.cache - def _is_dead_end(entrance: Entrance): + def _can_lead_to_randomizable_exits(self, entrance: Entrance): """ - Checks whether a entrance is an unconditional dead end, that is, no matter what you have, - it will never lead to new randomizable exits. + Checks whether an entrance is able to lead to another randomizable exit + with some combination of items + + :param entrance: A randomizable (no parent) region entrance """ - # obviously if this is an unpaired exit, then leads to unpaired exits! - if not entrance.connected_region: - return False - # if the connected region has no exits, it's a dead end. otherwise its exits must all be dead ends. - return not entrance.connected_region.exits or all(EntranceLookup._is_dead_end(exit) - for exit in entrance.connected_region.exits - if exit.name != entrance.name) + # we've seen this, return cached result + if entrance in self._leads_to_exits_cache: + return self._leads_to_exits_cache[entrance] + + visited = set() + q = queue.Queue() + q.put(entrance.connected_region) + + while not q.empty(): + region = q.get() + visited.add(region) + + for exit in region.exits: + # randomizable and not the reverse of the start entrance + if not exit.connected_region and exit.name != entrance.name: + self._leads_to_exits_cache[entrance] = True + return True + elif exit.connected_region and exit.connected_region not in visited: + q.put(exit.connected_region) + + self._leads_to_exits_cache[entrance] = False + return False def add(self, entrance: Entrance) -> None: - lookup = self.dead_ends if self._is_dead_end(entrance) else self.others + lookup = self.dead_ends if not self._can_lead_to_randomizable_exits(entrance) else self.others lookup.add(entrance) def remove(self, entrance: Entrance) -> None: - lookup = self.dead_ends if self._is_dead_end(entrance) else self.others + lookup = self.dead_ends if not self._can_lead_to_randomizable_exits(entrance) else self.others lookup.remove(entrance) def get_targets( @@ -66,7 +84,7 @@ def get_targets( groups: Iterable[str], dead_end: bool, preserve_group_order: bool - ) -> Iterable[Entrance]: + ) -> Iterable[Entrance]: lookup = self.dead_ends if dead_end else self.others if preserve_group_order: @@ -81,10 +99,10 @@ def get_targets( class ERPlacementState: # candidate exits to try and place right now! - _placeable_exits: Set[Entrance] + _placeable_exits: List[Entrance] # exits that are gated by some unmet requirement (either they are not valid source transitions yet # or they are static connections with unmet logic restrictions) - _pending_exits: Set[Entrance] + _pending_exits: List[Entrance] placed_regions: Set[Region] placements: List[Entrance] @@ -94,14 +112,15 @@ class ERPlacementState: coupled: bool def __init__(self, world: World, coupled: bool): - self._placeable_exits = set() - self._pending_exits = set() + self._placeable_exits = [] + self._pending_exits = [] self.placed_regions = set() self.placements = [] self.pairings = [] self.world = world self.collection_state = world.multiworld.get_all_state(True) + self.collection_state.allow_partial_entrances = True self.coupled = coupled def has_placeable_exits(self) -> bool: @@ -139,38 +158,41 @@ def place(self, start: Union[Region, Entrance]) -> None: # don't add it as a candidate. in uncoupled it's fair game. if not self.coupled or exit.name != starting_entrance_name: if exit.is_valid_source_transition(self): - self._placeable_exits.add(exit) + self._placeable_exits.append(exit) else: - self._pending_exits.add(exit) + self._pending_exits.append(exit) elif exit.connected_region not in self.placed_regions: # traverse unseen static connections - if exit.can_reach(self.collection_state, True): + if exit.can_reach(self.collection_state): q.put(exit.connected_region) else: - self._pending_exits.add(exit) + self._pending_exits.append(exit) def sweep_pending_exits(self) -> None: """ Checks if any exits which previously had unmet restrictions now have those restrictions met, and marks them for placement or places them depending on whether they are randomized or not. """ - no_longer_pending_exits = [] - for exit in self._pending_exits: - if exit.connected_region and exit.can_reach(self.collection_state, True): + size = len(self._pending_exits) + # iterate backwards so that removing items doesn't mess with indices of unprocessed items + for j, exit in enumerate(reversed(self._pending_exits)): + i = size - j - 1 + if exit.connected_region and exit.can_reach(self.collection_state): # this is an unrandomized entrance, so place it and propagate self.place(exit.connected_region) - no_longer_pending_exits.append(exit) + self._pending_exits.pop(i) elif not exit.connected_region and exit.is_valid_source_transition(self): # this is randomized so mark it eligible for placement - self._placeable_exits.add(exit) - no_longer_pending_exits.append(exit) - self._pending_exits.difference_update(no_longer_pending_exits) + self._placeable_exits.append(exit) + self._pending_exits.pop(i) def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: target_region = target_entrance.connected_region target_region.entrances.remove(target_entrance) source_exit.connect(target_region) + + self.collection_state.stale[self.world.player] = True self.placements.append(source_exit) self.pairings.append((source_exit.name, target_entrance.name)) @@ -189,17 +211,26 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[ # todo - better exceptions here for reverse_entrance in source_region.entrances: if reverse_entrance.name == source_exit.name: + if reverse_entrance.parent_region: + raise Exception("This is very bad") break else: raise Exception(f'Two way exit {source_exit.name} had no corresponding entrance in ' f'{source_exit.parent_region.name}') for reverse_exit in target_region.exits: if reverse_exit.name == target_entrance.name: + if reverse_exit.connected_region: + raise Exception("this is very bad") break else: raise Exception(f'Two way entrance {target_entrance.name} had no corresponding exit in ' f'{target_region.name}') self._connect_one_way(reverse_exit, reverse_entrance) + # the reverse exit might be in the placeable list so clear that to prevent re-randomization + try: + self._placeable_exits.remove(reverse_exit) + except ValueError: + pass return [target_entrance, reverse_entrance] return [target_entrance] @@ -211,7 +242,7 @@ def randomize_entrances( coupled: bool, get_target_groups: Callable[[str], List[str]], preserve_group_order: bool = False - ) -> ERPlacementState: +) -> ERPlacementState: """ Randomizes Entrances for a single world in the multiworld. This should usually be called in pre_fill or possibly set_rules if you know what you're doing. @@ -254,13 +285,14 @@ def randomize_entrances( # todo - this doesn't prioritize placing new rooms like the original did; # that's problematic because early loops would lead to failures # this is needed to reduce bias; otherwise newer exits are prioritized - # rng.shuffle(state._placeable_exits) + rng.shuffle(state._placeable_exits) source_exit = state._placeable_exits.pop() target_groups = get_target_groups(source_exit.er_group) # anything can connect to the default group - if people don't like it the fix is to # assign a non-default group - target_groups.append('Default') + if 'Default' not in target_groups: + target_groups.append('Default') for target_entrance in entrance_lookup.get_targets(target_groups, False, preserve_group_order): if source_exit.can_connect_to(target_entrance, state): # we found a valid, connectable target entrance. We'll connect it in a moment @@ -270,9 +302,9 @@ def randomize_entrances( unplaceable_exits.append(source_exit) continue - # place the new pairing - state.place(target_entrance) + # place the new pairing. it is important to do connections first so that can_reach will function. removed_entrances = state.connect(source_exit, target_entrance) + state.place(target_entrance) state.sweep_pending_exits() # remove paired entrances from the lookup so they don't get re-randomized for entrance in removed_entrances: @@ -285,20 +317,24 @@ def randomize_entrances( # none of the existing targets can pair to the existing sources. Since dead ends will never add new sources # this means the current targets can never be paired (in most cases) # todo - investigate ways to prevent this case + print("Unable to place all non-dead-end entrances with available source exits") return state # this short circuts the exception for testing purposes in order to see how far ER got. raise Exception("Unable to place all non-dead-end entrances with available source exits") # anything we couldn't place before might be placeable now - state._placeable_exits.union(unplaceable_exits) + state._placeable_exits.extend(unplaceable_exits) unplaceable_exits.clear() # repeat the above but try to place dead ends - while state.has_placeable_exits() and entrance_lookup.others: + while state.has_placeable_exits() and entrance_lookup.dead_ends: rng.shuffle(state._placeable_exits) source_exit = state._placeable_exits.pop() target_groups = get_target_groups(source_exit.er_group) - target_groups.append('Default') + # anything can connect to the default group - if people don't like it the fix is to + # assign a non-default group + if 'Default' not in target_groups: + target_groups.append('Default') for target_entrance in entrance_lookup.get_targets(target_groups, True, preserve_group_order): if source_exit.can_connect_to(target_entrance, state): # we found a valid, connectable target entrance. We'll connect it in a moment @@ -307,11 +343,13 @@ def randomize_entrances( # there were no valid dead-end targets for this source, so give up # todo - similar to above we should try and prevent this state. # also it can_connect_to may be stateful. + print("Unable to place all dead-end entrances with available source exits") + return state # this short circuts the exception for testing purposes in order to see how far ER got. raise Exception("Unable to place all dead-end entrances with available source exits") - # place the new pairing - state.place(target_entrance) + # place the new pairing. it is important to do connections first so that can_reach will function. removed_entrances = state.connect(source_exit, target_entrance) + state.place(target_entrance) state.sweep_pending_exits() # remove paired entrances from the lookup so they don't get re-randomized for entrance in removed_entrances: From 8241f5f9ee7a3f7d35d76f5e03d94deb8f6931b1 Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Fri, 24 Nov 2023 21:51:11 -0800 Subject: [PATCH 087/163] Use deque instead of queue.Queue --- EntranceRando.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index a17ce934d630..7635d67505bd 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -1,6 +1,7 @@ import functools import queue import random +from collections import deque from typing import Set, Tuple, List, Dict, Iterable, Callable, Union, Optional from BaseClasses import Region, Entrance, CollectionState @@ -53,11 +54,11 @@ def _can_lead_to_randomizable_exits(self, entrance: Entrance): return self._leads_to_exits_cache[entrance] visited = set() - q = queue.Queue() - q.put(entrance.connected_region) + q = deque() + q.append(entrance.connected_region) - while not q.empty(): - region = q.get() + while q: + region = q.popleft() visited.add(region) for exit in region.exits: @@ -66,7 +67,7 @@ def _can_lead_to_randomizable_exits(self, entrance: Entrance): self._leads_to_exits_cache[entrance] = True return True elif exit.connected_region and exit.connected_region not in visited: - q.put(exit.connected_region) + q.append(exit.connected_region) self._leads_to_exits_cache[entrance] = False return False @@ -134,16 +135,16 @@ def place(self, start: Union[Region, Entrance]) -> None: :param start: The starting region or entrance to traverse from. """ - q = queue.Queue[Region]() + q = deque() starting_entrance_name = None if isinstance(start, Entrance): starting_entrance_name = start.name - q.put(start.connected_region) + q.append(start.connected_region) else: - q.put(start) + q.append(start) - while not q.empty(): - region = q.get() + while q: + region = q.popleft() if region in self.placed_regions: continue self.placed_regions.add(region) @@ -164,7 +165,7 @@ def place(self, start: Union[Region, Entrance]) -> None: elif exit.connected_region not in self.placed_regions: # traverse unseen static connections if exit.can_reach(self.collection_state): - q.put(exit.connected_region) + q.append(exit.connected_region) else: self._pending_exits.append(exit) From e78b804327747106481b67965e28e787973fb9fc Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sun, 26 Nov 2023 15:59:31 -0800 Subject: [PATCH 088/163] Allow partial entrances in collection state earlier, doc improvements --- BaseClasses.py | 11 ++++++----- EntranceRando.py | 26 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 3927e32caebc..31c004eb5692 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -370,12 +370,12 @@ def get_entrance(self, entrance_name: str, player: int) -> Entrance: def get_location(self, location_name: str, player: int) -> Location: return self.regions.location_cache[player][location_name] - def get_all_state(self, use_cache: bool) -> CollectionState: + def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: return cached.copy() - ret = CollectionState(self) + ret = CollectionState(self, allow_partial_entrances) for item in self.itempool: self.worlds[item.player].collect(ret, item) @@ -616,11 +616,11 @@ class CollectionState(): path: Dict[Union[Region, Entrance], PathValue] locations_checked: Set[Location] stale: Dict[int, bool] + allow_partial_entrances: bool additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] - allow_partial_entrances: bool = False - def __init__(self, parent: MultiWorld): + def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): self.prog_items = {player: Counter() for player in parent.player_ids} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} @@ -629,6 +629,7 @@ def __init__(self, parent: MultiWorld): self.path = {} self.locations_checked = set() self.stale = {player: True for player in parent.get_all_ids()} + self.allow_partial_entrances = allow_partial_entrances for function in self.additional_init_functions: function(self, parent) for items in parent.precollected_items.values(): @@ -982,7 +983,7 @@ def create_exit(self, name: str) -> Entrance: self.exits.append(exit_) return exit_ - def create_er_entrance(self, name: str) -> Entrance: + def create_er_target(self, name: str) -> Entrance: """ Creates and returns an Entrance object as an entrance to this region diff --git a/EntranceRando.py b/EntranceRando.py index 7635d67505bd..2abd32ddd96c 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -120,8 +120,7 @@ def __init__(self, world: World, coupled: bool): self.placements = [] self.pairings = [] self.world = world - self.collection_state = world.multiworld.get_all_state(True) - self.collection_state.allow_partial_entrances = True + self.collection_state = world.multiworld.get_all_state(True, True) self.coupled = coupled def has_placeable_exits(self) -> bool: @@ -245,19 +244,28 @@ def randomize_entrances( preserve_group_order: bool = False ) -> ERPlacementState: """ - Randomizes Entrances for a single world in the multiworld. This should usually be - called in pre_fill or possibly set_rules if you know what you're doing. + Randomizes Entrances for a single world in the multiworld. + + Depending on how your world is configured, this may be called as early as create_regions or + need to be called as late as pre_fill. In general, earlier is better, ie the best time to + randomize entrances is as soon as the preconditions are fulfilled. Preconditions: 1. All of your Regions and all of their exits have been created. 2. Placeholder entrances have been created as the targets of randomization (each exit will be randomly paired to an entrance). - 3. Your Menu region is connected to your starting region - 4. All the region connections you don't want to randomize are connected; usually this + 3. All entrances and exits have been correctly labeled as 1 way or 2 way. + 4. Your Menu region is connected to your starting region. + 5. All the region connections you don't want to randomize are connected; usually this is connecting regions within a "scene" but may also include plando'd transitions. - 5. Access rules are set on all relevant region exits - 6. All event items and locations have been placed with access rules applied. - 7. All non-event items have been added to the item pool. + 6. Access rules are set on all relevant region exits. + * Access rules are used to conservatively prevent cases where, given a switch in region R_s + and the gate that it opens being the exit E_g to region R_g, the only way to access R_s + is through a connection R_g --(E_g)-> R_s, thus making R_s inaccessible. If you encode + this kind of cross-region dependency through events or indirect connections, those must + be placed/registered before calling this function if you want them to be respected. + * If you set access rules that contain items other than events, those items must be added to + the multiworld item pool before randomizing entrances. Postconditions: 1. All randomizable Entrances will be connected From 9f17b595caca40d36de90a1e476193e1fe225201 Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sun, 26 Nov 2023 17:40:31 -0800 Subject: [PATCH 089/163] Prevent early loops in region graph, improve reusability of ER stage code --- EntranceRando.py | 159 +++++++++++++++++++++++------------------------ 1 file changed, 77 insertions(+), 82 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index 2abd32ddd96c..63c2b9506dde 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -1,4 +1,5 @@ import functools +import itertools import queue import random from collections import deque @@ -18,6 +19,18 @@ def __init__(self): def __bool__(self): return bool(self._lookup) + def __getitem__(self, item: str) -> List[Entrance]: + return self._lookup.get(item, []) + + def __iter__(self): + return itertools.chain.from_iterable(self._lookup.values()) + + def __str__(self): + return str(self._lookup) + + def __repr__(self): + return self.__str__() + def add(self, entrance: Entrance) -> None: group = self._lookup.setdefault(entrance.er_group, []) group.append(entrance) @@ -28,9 +41,6 @@ def remove(self, entrance: Entrance) -> None: if not group: del self._lookup[entrance.er_group] - def __getitem__(self, item: str) -> List[Entrance]: - return self._lookup.get(item, []) - rng: random.Random dead_ends: GroupLookup others: GroupLookup @@ -272,13 +282,49 @@ def randomize_entrances( 2. All placeholder entrances to regions will have been removed. """ state = ERPlacementState(world, coupled) - # exits which had no candidate exits found in the non-dead-end stage. - # they will never be placeable until we start placing dead ends so - # hold them here and stop trying. - unplaceable_exits: List[Entrance] = [] entrance_lookup = EntranceLookup(rng) + def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[Entrance, Entrance]]: + for source_exit in state._placeable_exits: + target_groups = get_target_groups(source_exit.er_group) + # anything can connect to the default group - if people don't like it the fix is to + # assign a non-default group + if 'Default' not in target_groups: + target_groups.append('Default') + for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): + # todo - requiring new regions is a proxy for requiring new entrances to be unlocked, which is + # not quite full fidelity so we may need to revisit this in the future + region_requirement_satisfied = (not require_new_regions + or target_entrance.connected_region not in state.placed_regions) + if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, state): + return source_exit, target_entrance + else: + # no source exits had any valid target so this stage is deadlocked. swap may be implemented if early + # deadlocking is a frequent issue. + lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others + + # if we're in a stage where we're trying to get to new regions, we could also enter this + # branch in a success state (when all regions of the preferred type have been placed, but there are still + # additional unplaced entrances into those regions) + if require_new_regions: + if all(e.connected_region in state.placed_regions for e in lookup): + return None + + raise Exception(f"None of the available entrances had valid targets.\n" + f"Available exits: {state._placeable_exits}\n" + f"Available entrances: {lookup}") + + def do_placement(source_exit: Entrance, target_entrance: Entrance): + removed_entrances = state.connect(source_exit, target_entrance) + # remove the paired items from consideration + state._placeable_exits.remove(source_exit) + for entrance in removed_entrances: + entrance_lookup.remove(entrance) + # place and propagate + state.place(target_entrance) + state.sweep_pending_exits() + for region in regions: for entrance in region.entrances: if not entrance.parent_region: @@ -287,84 +333,33 @@ def randomize_entrances( # place the menu region and connected start region(s) state.place(world.multiworld.get_region('Menu', world.player)) - while state.has_placeable_exits() and entrance_lookup.others: + # stage 1 - try to place all the non-dead-end entrances + while entrance_lookup.others: # todo - this access to placeable_exits is ugly - # todo - this should iterate placeable exits instead of immediately - # giving up; can_connect_to may be stateful - # todo - this doesn't prioritize placing new rooms like the original did; - # that's problematic because early loops would lead to failures # this is needed to reduce bias; otherwise newer exits are prioritized rng.shuffle(state._placeable_exits) - source_exit = state._placeable_exits.pop() - - target_groups = get_target_groups(source_exit.er_group) - # anything can connect to the default group - if people don't like it the fix is to - # assign a non-default group - if 'Default' not in target_groups: - target_groups.append('Default') - for target_entrance in entrance_lookup.get_targets(target_groups, False, preserve_group_order): - if source_exit.can_connect_to(target_entrance, state): - # we found a valid, connectable target entrance. We'll connect it in a moment - break - else: - # there were no valid non-dead-end targets for this source, so give up on it for now - unplaceable_exits.append(source_exit) - continue - - # place the new pairing. it is important to do connections first so that can_reach will function. - removed_entrances = state.connect(source_exit, target_entrance) - state.place(target_entrance) - state.sweep_pending_exits() - # remove paired entrances from the lookup so they don't get re-randomized - for entrance in removed_entrances: - entrance_lookup.remove(entrance) - - if entrance_lookup.others: - # this is generally an unsalvagable failure, we would need to implement swap earlier in the process - # to prevent it. A stateful can_connect_to implementation may make this recoverable in some worlds as well. - # why? there are no placeable exits, which means none of them have valid targets, and conversely - # none of the existing targets can pair to the existing sources. Since dead ends will never add new sources - # this means the current targets can never be paired (in most cases) - # todo - investigate ways to prevent this case - print("Unable to place all non-dead-end entrances with available source exits") - return state # this short circuts the exception for testing purposes in order to see how far ER got. - raise Exception("Unable to place all non-dead-end entrances with available source exits") - - # anything we couldn't place before might be placeable now - state._placeable_exits.extend(unplaceable_exits) - unplaceable_exits.clear() - - # repeat the above but try to place dead ends - while state.has_placeable_exits() and entrance_lookup.dead_ends: + pairing = find_pairing(False, True) + if not pairing: + break + do_placement(*pairing) + # stage 2 - try to place all the dead-end entrances + while entrance_lookup.dead_ends: rng.shuffle(state._placeable_exits) - source_exit = state._placeable_exits.pop() - - target_groups = get_target_groups(source_exit.er_group) - # anything can connect to the default group - if people don't like it the fix is to - # assign a non-default group - if 'Default' not in target_groups: - target_groups.append('Default') - for target_entrance in entrance_lookup.get_targets(target_groups, True, preserve_group_order): - if source_exit.can_connect_to(target_entrance, state): - # we found a valid, connectable target entrance. We'll connect it in a moment - break - else: - # there were no valid dead-end targets for this source, so give up - # todo - similar to above we should try and prevent this state. - # also it can_connect_to may be stateful. - print("Unable to place all dead-end entrances with available source exits") - return state # this short circuts the exception for testing purposes in order to see how far ER got. - raise Exception("Unable to place all dead-end entrances with available source exits") - - # place the new pairing. it is important to do connections first so that can_reach will function. - removed_entrances = state.connect(source_exit, target_entrance) - state.place(target_entrance) - state.sweep_pending_exits() - # remove paired entrances from the lookup so they don't get re-randomized - for entrance in removed_entrances: - entrance_lookup.remove(entrance) - - if state.has_placeable_exits(): - raise Exception("There are more exits than entrances") + pairing = find_pairing(True, True) + if not pairing: + break + do_placement(*pairing) + # todo - stages 3 and 4 should ideally run "together" ie without respect to dead-endedness + # as we are just trying to tie off loose ends rather than get you somewhere new + # stage 3 - connect any dangling entrances that remain + while entrance_lookup.others: + rng.shuffle(state._placeable_exits) + pairing = find_pairing(False, False) + do_placement(*pairing) + # stage 4 - last chance for dead ends + while entrance_lookup.dead_ends: + rng.shuffle(state._placeable_exits) + pairing = find_pairing(True, False) + do_placement(*pairing) return state From 2977509f4f64f542bf54f5e285627b75047d1cb2 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Dec 2023 06:09:33 -0600 Subject: [PATCH 090/163] Typos, grammar, PEP8, and style "fixes" --- BaseClasses.py | 21 ++++--- EntranceRando.py | 144 ++++++++++++++++++++++++----------------------- 2 files changed, 83 insertions(+), 82 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index c54dadd9167f..b20b2425b6ff 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -627,7 +627,7 @@ class CollectionState(): additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): - self.prog_items = {player: Counter() for player in parent.player_ids} + self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()} @@ -662,10 +662,9 @@ def update_reachable_regions(self, player: int): if new_region in rrp: bc.remove(connection) elif connection.can_reach(self): - if not self.allow_partial_entrances: - assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - elif not new_region: + if self.allow_partial_entrances and not new_region: continue + assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" rrp.add(new_region) bc.remove(connection) bc.update(new_region.exits) @@ -786,7 +785,7 @@ def remove(self, item: Item): class Entrance: - class Type(IntEnum): + class EntranceType(IntEnum): ONE_WAY = 1 TWO_WAY = 2 @@ -797,13 +796,13 @@ class Type(IntEnum): parent_region: Optional[Region] connected_region: Optional[Region] = None er_group: str - er_type: Type + er_type: EntranceType # LttP specific, TODO: should make a LttPEntrance addresses = None target = None - def __init__(self, player: int, name: str = '', parent: Region = None, - er_group: str = 'Default', er_type: Type = Type.ONE_WAY): + def __init__(self, player: int, name: str = "", parent: Region = None, + er_group: str = "Default", er_type: EntranceType = EntranceType.ONE_WAY): self.name = name self.parent_region = parent self.player = player @@ -812,7 +811,7 @@ def __init__(self, player: int, name: str = '', parent: Region = None, def can_reach(self, state: CollectionState) -> bool: if self.parent_region.can_reach(state) and self.access_rule(state): - if not self.hide_path and not self in state.path: + if not self.hide_path and self not in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) return True @@ -824,7 +823,7 @@ def connect(self, region: Region, addresses: Any = None, target: Any = None) -> self.addresses = addresses region.entrances.append(self) - def is_valid_source_transition(self, state: ERPlacementState) -> bool: + def is_valid_source_transition(self, state: "ERPlacementState") -> bool: """ Determines whether this is a valid source transition, that is, whether the entrance randomizer is allowed to pair it to place any other regions. By default, this is the @@ -835,7 +834,7 @@ def is_valid_source_transition(self, state: ERPlacementState) -> bool: """ return self.can_reach(state.collection_state) - def can_connect_to(self, other: Entrance, state: ERPlacementState) -> bool: + def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: """ Determines whether a given Entrance is a valid target transition, that is, whether the entrance randomizer is allowed to pair this Entrance to that Entrance. diff --git a/EntranceRando.py b/EntranceRando.py index 63c2b9506dde..875e5e55f6f3 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -1,11 +1,9 @@ -import functools import itertools -import queue import random from collections import deque -from typing import Set, Tuple, List, Dict, Iterable, Callable, Union, Optional +from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple, Union -from BaseClasses import Region, Entrance, CollectionState +from BaseClasses import CollectionState, Entrance, Region from worlds.AutoWorld import World @@ -32,8 +30,7 @@ def __repr__(self): return self.__str__() def add(self, entrance: Entrance) -> None: - group = self._lookup.setdefault(entrance.er_group, []) - group.append(entrance) + self._lookup.setdefault(entrance.er_group, []).append(entrance) def remove(self, entrance: Entrance) -> None: group = self._lookup.get(entrance.er_group, []) @@ -41,15 +38,15 @@ def remove(self, entrance: Entrance) -> None: if not group: del self._lookup[entrance.er_group] - rng: random.Random dead_ends: GroupLookup others: GroupLookup + _random: random.Random _leads_to_exits_cache: Dict[Entrance, bool] def __init__(self, rng: random.Random): - self.rng = rng self.dead_ends = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup() + self._random = rng self._leads_to_exits_cache = {} def _can_lead_to_randomizable_exits(self, entrance: Entrance): @@ -71,23 +68,23 @@ def _can_lead_to_randomizable_exits(self, entrance: Entrance): region = q.popleft() visited.add(region) - for exit in region.exits: + for exit_ in region.exits: # randomizable and not the reverse of the start entrance - if not exit.connected_region and exit.name != entrance.name: + if not exit_.connected_region and exit_.name != entrance.name: self._leads_to_exits_cache[entrance] = True return True - elif exit.connected_region and exit.connected_region not in visited: - q.append(exit.connected_region) + elif exit_.connected_region and exit_.connected_region not in visited: + q.append(exit_.connected_region) self._leads_to_exits_cache[entrance] = False return False def add(self, entrance: Entrance) -> None: - lookup = self.dead_ends if not self._can_lead_to_randomizable_exits(entrance) else self.others + lookup = self.others if self._can_lead_to_randomizable_exits(entrance) else self.dead_ends lookup.add(entrance) def remove(self, entrance: Entrance) -> None: - lookup = self.dead_ends if not self._can_lead_to_randomizable_exits(entrance) else self.others + lookup = self.others if self._can_lead_to_randomizable_exits(entrance) else self.dead_ends lookup.remove(entrance) def get_targets( @@ -100,20 +97,20 @@ def get_targets( lookup = self.dead_ends if dead_end else self.others if preserve_group_order: for group in groups: - self.rng.shuffle(lookup[group]) + self._random.shuffle(lookup[group]) ret = [entrance for group in groups for entrance in lookup[group]] else: ret = [entrance for group in groups for entrance in lookup[group]] - self.rng.shuffle(ret) + self._random.shuffle(ret) return ret class ERPlacementState: - # candidate exits to try and place right now! - _placeable_exits: List[Entrance] - # exits that are gated by some unmet requirement (either they are not valid source transitions yet - # or they are static connections with unmet logic restrictions) + placeable_exits: List[Entrance] + """candidate exits to try and place right now""" _pending_exits: List[Entrance] + """exits that are gated by some unmet requirement""" + # (either they are not valid source transitions yet or they are static connections with unmet logic restrictions) placed_regions: Set[Region] placements: List[Entrance] @@ -123,18 +120,19 @@ class ERPlacementState: coupled: bool def __init__(self, world: World, coupled: bool): - self._placeable_exits = [] + self.placeable_exits = [] self._pending_exits = [] self.placed_regions = set() self.placements = [] self.pairings = [] self.world = world - self.collection_state = world.multiworld.get_all_state(True, True) + self.collection_state = world.multiworld.get_all_state(False, True) self.coupled = coupled + @property def has_placeable_exits(self) -> bool: - return bool(self._placeable_exits) + return bool(self.placeable_exits) def place(self, start: Union[Region, Entrance]) -> None: """ @@ -161,22 +159,22 @@ def place(self, start: Union[Region, Entrance]) -> None: local_locations = self.world.multiworld.get_locations(self.world.player) self.collection_state.sweep_for_events(locations=local_locations) # traverse exits - for exit in region.exits: + for exit_ in region.exits: # if the exit is unconnected, it's a candidate for randomization - if not exit.connected_region: + if not exit_.connected_region: # in coupled, the reverse transition will be handled specially; # don't add it as a candidate. in uncoupled it's fair game. - if not self.coupled or exit.name != starting_entrance_name: - if exit.is_valid_source_transition(self): - self._placeable_exits.append(exit) + if not self.coupled or exit_.name != starting_entrance_name: + if exit_.is_valid_source_transition(self): + self.placeable_exits.append(exit_) else: - self._pending_exits.append(exit) - elif exit.connected_region not in self.placed_regions: + self._pending_exits.append(exit_) + elif exit_.connected_region not in self.placed_regions: # traverse unseen static connections - if exit.can_reach(self.collection_state): - q.append(exit.connected_region) + if exit_.can_reach(self.collection_state): + q.append(exit_.connected_region) else: - self._pending_exits.append(exit) + self._pending_exits.append(exit_) def sweep_pending_exits(self) -> None: """ @@ -185,15 +183,15 @@ def sweep_pending_exits(self) -> None: """ size = len(self._pending_exits) # iterate backwards so that removing items doesn't mess with indices of unprocessed items - for j, exit in enumerate(reversed(self._pending_exits)): + for j, exit_ in enumerate(reversed(self._pending_exits)): i = size - j - 1 - if exit.connected_region and exit.can_reach(self.collection_state): + if exit_.connected_region and exit_.can_reach(self.collection_state): # this is an unrandomized entrance, so place it and propagate - self.place(exit.connected_region) + self.place(exit_.connected_region) self._pending_exits.pop(i) - elif not exit.connected_region and exit.is_valid_source_transition(self): + elif not exit_.connected_region and exit_.is_valid_source_transition(self): # this is randomized so mark it eligible for placement - self._placeable_exits.append(exit) + self.placeable_exits.append(exit_) self._pending_exits.pop(i) def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: @@ -217,28 +215,28 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[ self._connect_one_way(source_exit, target_entrance) # if we're doing coupled randomization place the reverse transition as well. - if self.coupled and source_exit.er_type == Entrance.Type.TWO_WAY: - # todo - better exceptions here + if self.coupled and source_exit.er_type == Entrance.EntranceType.TWO_WAY: + # TODO - better exceptions here for reverse_entrance in source_region.entrances: if reverse_entrance.name == source_exit.name: if reverse_entrance.parent_region: raise Exception("This is very bad") break else: - raise Exception(f'Two way exit {source_exit.name} had no corresponding entrance in ' - f'{source_exit.parent_region.name}') + raise Exception(f"Two way exit {source_exit.name} had no corresponding entrance in " + f"{source_exit.parent_region.name}") for reverse_exit in target_region.exits: if reverse_exit.name == target_entrance.name: if reverse_exit.connected_region: raise Exception("this is very bad") break else: - raise Exception(f'Two way entrance {target_entrance.name} had no corresponding exit in ' - f'{target_region.name}') + raise Exception(f"Two way entrance {target_entrance.name} had no corresponding exit in " + f"{target_region.name}") self._connect_one_way(reverse_exit, reverse_entrance) # the reverse exit might be in the placeable list so clear that to prevent re-randomization try: - self._placeable_exits.remove(reverse_exit) + self.placeable_exits.remove(reverse_exit) except ValueError: pass return [target_entrance, reverse_entrance] @@ -247,7 +245,6 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[ def randomize_entrances( world: World, - rng: random.Random, regions: Iterable[Region], coupled: bool, get_target_groups: Callable[[str], List[str]], @@ -277,27 +274,32 @@ def randomize_entrances( * If you set access rules that contain items other than events, those items must be added to the multiworld item pool before randomizing entrances. - Postconditions: + Post-conditions: 1. All randomizable Entrances will be connected 2. All placeholder entrances to regions will have been removed. - """ - state = ERPlacementState(world, coupled) - entrance_lookup = EntranceLookup(rng) + :param world: Your World instance + :param regions: Regions with no connected entrances that you would like to be randomly connected + :param coupled: Whether connected entrances should be coupled to go in both directions + :param get_target_groups: Method to call that returns the groups that a specific group type is allowed to connect to + :param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups + """ + er_state = ERPlacementState(world, coupled) + entrance_lookup = EntranceLookup(world.random) def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[Entrance, Entrance]]: - for source_exit in state._placeable_exits: + for source_exit in er_state.placeable_exits: target_groups = get_target_groups(source_exit.er_group) # anything can connect to the default group - if people don't like it the fix is to # assign a non-default group - if 'Default' not in target_groups: - target_groups.append('Default') + if "Default" not in target_groups: + target_groups.append("Default") for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): - # todo - requiring new regions is a proxy for requiring new entrances to be unlocked, which is + # TODO - requiring new regions is a proxy for requiring new entrances to be unlocked, which is # not quite full fidelity so we may need to revisit this in the future region_requirement_satisfied = (not require_new_regions - or target_entrance.connected_region not in state.placed_regions) - if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, state): + or target_entrance.connected_region not in er_state.placed_regions) + if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, er_state): return source_exit, target_entrance else: # no source exits had any valid target so this stage is deadlocked. swap may be implemented if early @@ -308,22 +310,22 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[En # branch in a success state (when all regions of the preferred type have been placed, but there are still # additional unplaced entrances into those regions) if require_new_regions: - if all(e.connected_region in state.placed_regions for e in lookup): + if all(e.connected_region in er_state.placed_regions for e in lookup): return None raise Exception(f"None of the available entrances had valid targets.\n" - f"Available exits: {state._placeable_exits}\n" + f"Available exits: {er_state.placeable_exits}\n" f"Available entrances: {lookup}") def do_placement(source_exit: Entrance, target_entrance: Entrance): - removed_entrances = state.connect(source_exit, target_entrance) + removed_entrances = er_state.connect(source_exit, target_entrance) # remove the paired items from consideration - state._placeable_exits.remove(source_exit) + er_state.placeable_exits.remove(source_exit) for entrance in removed_entrances: entrance_lookup.remove(entrance) # place and propagate - state.place(target_entrance) - state.sweep_pending_exits() + er_state.place(target_entrance) + er_state.sweep_pending_exits() for region in regions: for entrance in region.entrances: @@ -331,35 +333,35 @@ def do_placement(source_exit: Entrance, target_entrance: Entrance): entrance_lookup.add(entrance) # place the menu region and connected start region(s) - state.place(world.multiworld.get_region('Menu', world.player)) + er_state.place(world.multiworld.get_region("Menu", world.player)) # stage 1 - try to place all the non-dead-end entrances while entrance_lookup.others: - # todo - this access to placeable_exits is ugly - # this is needed to reduce bias; otherwise newer exits are prioritized - rng.shuffle(state._placeable_exits) + # TODO - this access to placeable_exits is ugly + # this is needed to reduce bias; otherwise newer exits are prioritized + world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(False, True) if not pairing: break do_placement(*pairing) # stage 2 - try to place all the dead-end entrances while entrance_lookup.dead_ends: - rng.shuffle(state._placeable_exits) + world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(True, True) if not pairing: break do_placement(*pairing) - # todo - stages 3 and 4 should ideally run "together" ie without respect to dead-endedness + # TODO - stages 3 and 4 should ideally run "together"; i.e. without respect to dead-endedness # as we are just trying to tie off loose ends rather than get you somewhere new # stage 3 - connect any dangling entrances that remain while entrance_lookup.others: - rng.shuffle(state._placeable_exits) + world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(False, False) do_placement(*pairing) # stage 4 - last chance for dead ends while entrance_lookup.dead_ends: - rng.shuffle(state._placeable_exits) + world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(True, False) do_placement(*pairing) - return state + return er_state From 144d8f4b2db696dfeaeba0dcf1fa79403e5b50e5 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Dec 2023 06:35:05 -0600 Subject: [PATCH 091/163] use RuntimeError instead of bare Exceptions --- EntranceRando.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index 875e5e55f6f3..b8ae57bfaef2 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -216,23 +216,23 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[ self._connect_one_way(source_exit, target_entrance) # if we're doing coupled randomization place the reverse transition as well. if self.coupled and source_exit.er_type == Entrance.EntranceType.TWO_WAY: - # TODO - better exceptions here + # TODO - better exceptions here - maybe a custom Error class? for reverse_entrance in source_region.entrances: if reverse_entrance.name == source_exit.name: if reverse_entrance.parent_region: - raise Exception("This is very bad") + raise RuntimeError("This is very bad") break else: - raise Exception(f"Two way exit {source_exit.name} had no corresponding entrance in " - f"{source_exit.parent_region.name}") + raise RuntimeError(f"Two way exit {source_exit.name} had no corresponding entrance in " + f"{source_exit.parent_region.name}") for reverse_exit in target_region.exits: if reverse_exit.name == target_entrance.name: if reverse_exit.connected_region: - raise Exception("this is very bad") + raise RuntimeError("this is very bad") break else: - raise Exception(f"Two way entrance {target_entrance.name} had no corresponding exit in " - f"{target_region.name}") + raise RuntimeError(f"Two way entrance {target_entrance.name} had no corresponding exit in " + f"{target_region.name}") self._connect_one_way(reverse_exit, reverse_entrance) # the reverse exit might be in the placeable list so clear that to prevent re-randomization try: @@ -313,9 +313,9 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[En if all(e.connected_region in er_state.placed_regions for e in lookup): return None - raise Exception(f"None of the available entrances had valid targets.\n" - f"Available exits: {er_state.placeable_exits}\n" - f"Available entrances: {lookup}") + raise RuntimeError(f"None of the available exits are valid targets for the available entrances.\n" + f"Available entrances: {lookup}\n" + f"Available exits: {er_state.placeable_exits}") def do_placement(source_exit: Entrance, target_entrance: Entrance): removed_entrances = er_state.connect(source_exit, target_entrance) From 95dfd2782bc01d7f8310eb289eba433a807b585e Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Dec 2023 06:35:24 -0600 Subject: [PATCH 092/163] return tuples from connect since it's slightly faster for our purposes --- EntranceRando.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index b8ae57bfaef2..4f4d1383cbbc 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -204,7 +204,7 @@ def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> self.placements.append(source_exit) self.pairings.append((source_exit.name, target_entrance.name)) - def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[Entrance]: + def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Union[Tuple[Entrance], Tuple[Entrance, Entrance]]: """ Connects a source exit to a target entrance in the graph, accounting for coupling @@ -239,8 +239,8 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Iterable[ self.placeable_exits.remove(reverse_exit) except ValueError: pass - return [target_entrance, reverse_entrance] - return [target_entrance] + return target_entrance, reverse_entrance + return target_entrance, def randomize_entrances( From 5806ac3ba6ee5be8657726cc00a2cc95474b8b48 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Dec 2023 06:39:55 -0600 Subject: [PATCH 093/163] move the shuffle to the beginning of find_pairing --- EntranceRando.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index 4f4d1383cbbc..28c47dd4f65e 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -288,6 +288,7 @@ def randomize_entrances( entrance_lookup = EntranceLookup(world.random) def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[Entrance, Entrance]]: + world.random.shuffle(er_state.placeable_exits) for source_exit in er_state.placeable_exits: target_groups = get_target_groups(source_exit.er_group) # anything can connect to the default group - if people don't like it the fix is to @@ -337,16 +338,12 @@ def do_placement(source_exit: Entrance, target_entrance: Entrance): # stage 1 - try to place all the non-dead-end entrances while entrance_lookup.others: - # TODO - this access to placeable_exits is ugly - # this is needed to reduce bias; otherwise newer exits are prioritized - world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(False, True) if not pairing: break do_placement(*pairing) # stage 2 - try to place all the dead-end entrances while entrance_lookup.dead_ends: - world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(True, True) if not pairing: break @@ -355,12 +352,10 @@ def do_placement(source_exit: Entrance, target_entrance: Entrance): # as we are just trying to tie off loose ends rather than get you somewhere new # stage 3 - connect any dangling entrances that remain while entrance_lookup.others: - world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(False, False) do_placement(*pairing) # stage 4 - last chance for dead ends while entrance_lookup.dead_ends: - world.random.shuffle(er_state.placeable_exits) pairing = find_pairing(True, False) do_placement(*pairing) From 11e4d83b39849cc657c97f27d44e44f2d5b2791a Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 1 Dec 2023 06:54:20 -0600 Subject: [PATCH 094/163] do er_state placements within pairing lookups to remove code duplication --- EntranceRando.py | 46 ++++++++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index 28c47dd4f65e..7add16c2896e 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -287,7 +287,17 @@ def randomize_entrances( er_state = ERPlacementState(world, coupled) entrance_lookup = EntranceLookup(world.random) - def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[Entrance, Entrance]]: + def do_placement(source_exit: Entrance, target_entrance: Entrance): + removed_entrances = er_state.connect(source_exit, target_entrance) + # remove the paired items from consideration + er_state.placeable_exits.remove(source_exit) + for entrance in removed_entrances: + entrance_lookup.remove(entrance) + # place and propagate + er_state.place(target_entrance) + er_state.sweep_pending_exits() + + def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: world.random.shuffle(er_state.placeable_exits) for source_exit in er_state.placeable_exits: target_groups = get_target_groups(source_exit.er_group) @@ -298,10 +308,10 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[En for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): # TODO - requiring new regions is a proxy for requiring new entrances to be unlocked, which is # not quite full fidelity so we may need to revisit this in the future - region_requirement_satisfied = (not require_new_regions - or target_entrance.connected_region not in er_state.placed_regions) - if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, er_state): - return source_exit, target_entrance + if ((not require_new_regions or target_entrance.connected_region not in er_state.placed_regions) + and source_exit.can_connect_to(target_entrance, er_state)): + do_placement(source_exit, target_entrance) + return True else: # no source exits had any valid target so this stage is deadlocked. swap may be implemented if early # deadlocking is a frequent issue. @@ -312,22 +322,12 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[En # additional unplaced entrances into those regions) if require_new_regions: if all(e.connected_region in er_state.placed_regions for e in lookup): - return None + return False raise RuntimeError(f"None of the available exits are valid targets for the available entrances.\n" f"Available entrances: {lookup}\n" f"Available exits: {er_state.placeable_exits}") - def do_placement(source_exit: Entrance, target_entrance: Entrance): - removed_entrances = er_state.connect(source_exit, target_entrance) - # remove the paired items from consideration - er_state.placeable_exits.remove(source_exit) - for entrance in removed_entrances: - entrance_lookup.remove(entrance) - # place and propagate - er_state.place(target_entrance) - er_state.sweep_pending_exits() - for region in regions: for entrance in region.entrances: if not entrance.parent_region: @@ -338,25 +338,19 @@ def do_placement(source_exit: Entrance, target_entrance: Entrance): # stage 1 - try to place all the non-dead-end entrances while entrance_lookup.others: - pairing = find_pairing(False, True) - if not pairing: + if not find_pairing(False, True): break - do_placement(*pairing) # stage 2 - try to place all the dead-end entrances while entrance_lookup.dead_ends: - pairing = find_pairing(True, True) - if not pairing: + if not find_pairing(True, True): break - do_placement(*pairing) # TODO - stages 3 and 4 should ideally run "together"; i.e. without respect to dead-endedness # as we are just trying to tie off loose ends rather than get you somewhere new # stage 3 - connect any dangling entrances that remain while entrance_lookup.others: - pairing = find_pairing(False, False) - do_placement(*pairing) + find_pairing(False, False) # stage 4 - last chance for dead ends while entrance_lookup.dead_ends: - pairing = find_pairing(True, False) - do_placement(*pairing) + find_pairing(True, False) return er_state From cbb31f4dc4aee1661cc00141c5e66e7ed9036afa Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 2 Dec 2023 14:20:18 -0600 Subject: [PATCH 095/163] requested adjustments --- EntranceRando.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index 7add16c2896e..9998407d5620 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -308,8 +308,9 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): # TODO - requiring new regions is a proxy for requiring new entrances to be unlocked, which is # not quite full fidelity so we may need to revisit this in the future - if ((not require_new_regions or target_entrance.connected_region not in er_state.placed_regions) - and source_exit.can_connect_to(target_entrance, er_state)): + region_requirement_satisfied = (not require_new_regions + or target_entrance.connected_region not in er_state.placed_regions) + if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, er_state): do_placement(source_exit, target_entrance) return True else: @@ -324,7 +325,7 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: if all(e.connected_region in er_state.placed_regions for e in lookup): return False - raise RuntimeError(f"None of the available exits are valid targets for the available entrances.\n" + raise RuntimeError(f"None of the available entrances are valid targets for the available exits.\n" f"Available entrances: {lookup}\n" f"Available exits: {er_state.placeable_exits}") From 486d3c6759418efc1bc6140f9ec49ae1d795434f Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Mon, 4 Dec 2023 23:39:57 -0800 Subject: [PATCH 096/163] Add some temporary performance logging --- EntranceRando.py | 8 ++++++++ worlds/AutoWorld.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/EntranceRando.py b/EntranceRando.py index 9998407d5620..7e512f7bae96 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -1,5 +1,7 @@ import itertools +import logging import random +import time from collections import deque from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple, Union @@ -284,6 +286,7 @@ def randomize_entrances( :param get_target_groups: Method to call that returns the groups that a specific group type is allowed to connect to :param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups """ + start_time = time.perf_counter() er_state = ERPlacementState(world, coupled) entrance_lookup = EntranceLookup(world.random) @@ -354,4 +357,9 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: while entrance_lookup.dead_ends: find_pairing(True, False) + # TODO - gate this behind some condition or debug level or something for production use + running_time = time.perf_counter() - start_time + logging.info(f"Completed entrance randomization for player {world.player} with " + f"name {world.multiworld.player_name[world.player]} in {running_time:.4f} seconds") + return er_state diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 5d0533e068d6..d2dab06ac3d1 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -118,7 +118,8 @@ def _timed_call(method: Callable[..., Any], *args: Any, start = time.perf_counter() ret = method(*args) taken = time.perf_counter() - start - if taken > 1.0: + # TODO - change this condition back or gate it behind debug level or something for production use + if taken > 0.0: if player and multiworld: perf_logger.info(f"Took {taken:.4f} seconds in {method.__qualname__} for player {player}, " f"named {multiworld.player_name[player]}.") From 5d7fe131e525318550d3a7d7d1c6ea36bb05d191 Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sat, 9 Dec 2023 21:45:28 -0800 Subject: [PATCH 097/163] Use CollectionState to track available exits and placed regions --- EntranceRando.py | 110 ++++++++++------------------------------------- 1 file changed, 22 insertions(+), 88 deletions(-) diff --git a/EntranceRando.py b/EntranceRando.py index 7e512f7bae96..8b85f6b75412 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -3,7 +3,7 @@ import random import time from collections import deque -from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple, Union +from typing import Callable, Dict, Iterable, List, Tuple, Union, Set from BaseClasses import CollectionState, Entrance, Region from worlds.AutoWorld import World @@ -108,13 +108,6 @@ def get_targets( class ERPlacementState: - placeable_exits: List[Entrance] - """candidate exits to try and place right now""" - _pending_exits: List[Entrance] - """exits that are gated by some unmet requirement""" - # (either they are not valid source transitions yet or they are static connections with unmet logic restrictions) - - placed_regions: Set[Region] placements: List[Entrance] pairings: List[Tuple[str, str]] world: World @@ -122,10 +115,6 @@ class ERPlacementState: coupled: bool def __init__(self, world: World, coupled: bool): - self.placeable_exits = [] - self._pending_exits = [] - - self.placed_regions = set() self.placements = [] self.pairings = [] self.world = world @@ -133,68 +122,16 @@ def __init__(self, world: World, coupled: bool): self.coupled = coupled @property - def has_placeable_exits(self) -> bool: - return bool(self.placeable_exits) - - def place(self, start: Union[Region, Entrance]) -> None: - """ - Traverses a region's connected exits to find any newly available randomizable - exits which stem from that region. + def placed_regions(self) -> Set[Region]: + return self.collection_state.reachable_regions[self.world.player] - :param start: The starting region or entrance to traverse from. - """ - - q = deque() - starting_entrance_name = None - if isinstance(start, Entrance): - starting_entrance_name = start.name - q.append(start.connected_region) - else: - q.append(start) - - while q: - region = q.popleft() - if region in self.placed_regions: - continue - self.placed_regions.add(region) - # collect events - local_locations = self.world.multiworld.get_locations(self.world.player) - self.collection_state.sweep_for_events(locations=local_locations) - # traverse exits - for exit_ in region.exits: - # if the exit is unconnected, it's a candidate for randomization - if not exit_.connected_region: - # in coupled, the reverse transition will be handled specially; - # don't add it as a candidate. in uncoupled it's fair game. - if not self.coupled or exit_.name != starting_entrance_name: - if exit_.is_valid_source_transition(self): - self.placeable_exits.append(exit_) - else: - self._pending_exits.append(exit_) - elif exit_.connected_region not in self.placed_regions: - # traverse unseen static connections - if exit_.can_reach(self.collection_state): - q.append(exit_.connected_region) - else: - self._pending_exits.append(exit_) - - def sweep_pending_exits(self) -> None: - """ - Checks if any exits which previously had unmet restrictions now have those restrictions met, - and marks them for placement or places them depending on whether they are randomized or not. - """ - size = len(self._pending_exits) - # iterate backwards so that removing items doesn't mess with indices of unprocessed items - for j, exit_ in enumerate(reversed(self._pending_exits)): - i = size - j - 1 - if exit_.connected_region and exit_.can_reach(self.collection_state): - # this is an unrandomized entrance, so place it and propagate - self.place(exit_.connected_region) - self._pending_exits.pop(i) - elif not exit_.connected_region and exit_.is_valid_source_transition(self): - # this is randomized so mark it eligible for placement - self.placeable_exits.append(exit_) - self._pending_exits.pop(i) + def find_placeable_exits(self) -> List[Entrance]: + blocked_connections = self.collection_state.blocked_connections[self.world.player] + placeable_randomized_exits = [connection for connection in blocked_connections + if not connection.connected_region + and connection.is_valid_source_transition(self)] + self.world.random.shuffle(placeable_randomized_exits) + return placeable_randomized_exits def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: target_region = target_entrance.connected_region @@ -206,7 +143,11 @@ def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> self.placements.append(source_exit) self.pairings.append((source_exit.name, target_entrance.name)) - def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Union[Tuple[Entrance], Tuple[Entrance, Entrance]]: + def connect( + self, + source_exit: Entrance, + target_entrance: Entrance + ) -> Union[Tuple[Entrance], Tuple[Entrance, Entrance]]: """ Connects a source exit to a target entrance in the graph, accounting for coupling @@ -236,11 +177,6 @@ def connect(self, source_exit: Entrance, target_entrance: Entrance) -> Union[Tup raise RuntimeError(f"Two way entrance {target_entrance.name} had no corresponding exit in " f"{target_region.name}") self._connect_one_way(reverse_exit, reverse_entrance) - # the reverse exit might be in the placeable list so clear that to prevent re-randomization - try: - self.placeable_exits.remove(reverse_exit) - except ValueError: - pass return target_entrance, reverse_entrance return target_entrance, @@ -292,17 +228,15 @@ def randomize_entrances( def do_placement(source_exit: Entrance, target_entrance: Entrance): removed_entrances = er_state.connect(source_exit, target_entrance) - # remove the paired items from consideration - er_state.placeable_exits.remove(source_exit) + # remove the placed targets from consideration for entrance in removed_entrances: entrance_lookup.remove(entrance) - # place and propagate - er_state.place(target_entrance) - er_state.sweep_pending_exits() + # propagate new connections + er_state.collection_state.update_reachable_regions(world.player) def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: - world.random.shuffle(er_state.placeable_exits) - for source_exit in er_state.placeable_exits: + placeable_exits = er_state.find_placeable_exits() + for source_exit in placeable_exits: target_groups = get_target_groups(source_exit.er_group) # anything can connect to the default group - if people don't like it the fix is to # assign a non-default group @@ -330,7 +264,7 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: raise RuntimeError(f"None of the available entrances are valid targets for the available exits.\n" f"Available entrances: {lookup}\n" - f"Available exits: {er_state.placeable_exits}") + f"Available exits: {placeable_exits}") for region in regions: for entrance in region.entrances: @@ -338,7 +272,7 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: entrance_lookup.add(entrance) # place the menu region and connected start region(s) - er_state.place(world.multiworld.get_region("Menu", world.player)) + er_state.collection_state.update_reachable_regions(world.player) # stage 1 - try to place all the non-dead-end entrances while entrance_lookup.others: From c01d36e7c358107705d501fad7c5526cf989290e Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 7 Jan 2024 07:26:22 -0600 Subject: [PATCH 098/163] remove seal shuffle option --- worlds/messenger/__init__.py | 7 +++---- worlds/messenger/options.py | 13 ++++--------- worlds/messenger/rules.py | 14 ++------------ worlds/messenger/subclasses.py | 26 ++++++++++++++------------ worlds/messenger/test/test_logic.py | 3 --- 5 files changed, 23 insertions(+), 40 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 88d46f8ee46e..2e8b743a47c8 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -8,7 +8,7 @@ from worlds.LauncherComponents import Component, Type, components from .client_setup import launch_game from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS -from .options import Goal, Logic, MessengerOptions, NotesNeeded, PowerSeals +from .options import Goal, Logic, MessengerOptions, NotesNeeded from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices @@ -91,13 +91,12 @@ class MessengerWorld(World): def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: - self.options.shuffle_seals.value = PowerSeals.option_true self.total_seals = self.options.total_seals.value if self.options.limited_movement: - self.options.shuffle_seals.value = PowerSeals.option_true - self.options.logic_level.value = Logic.option_hard self.options.accessibility.value = Accessibility.option_minimal + if self.options.logic_level < Logic.option_hard: + self.options.logic_level.value = Logic.option_hard self.multiworld.early_items[self.player]["Meditation"] = self.options.early_meditation.value diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index b5831840eb79..974c0331f02a 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -28,18 +28,15 @@ class Logic(Choice): alias_challenging = 1 -class PowerSeals(DefaultOnToggle): - """Whether power seal locations should be randomized.""" - display_name = "Shuffle Seals" - - class MegaShards(Toggle): """Whether mega shards should be item locations.""" display_name = "Shuffle Mega Time Shards" class LimitedMovement(Toggle): - """Removes either rope dart or wingsuit from the itempool. Forces seals to be shuffled, and logic to hard.""" + """ + Removes either rope dart or wingsuit from the itempool. Forces logic to at least hard and accessibility to minimal. + """ display_name = "Limited Movement" @@ -49,7 +46,7 @@ class EarlyMed(Toggle): class Goal(Choice): - """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" + """Requirement to finish the game.""" display_name = "Goal" option_open_music_box = 0 option_power_seal_hunt = 1 @@ -147,7 +144,6 @@ class MessengerOptions(PerGameCommonOptions): accessibility: MessengerAccessibility start_inventory: StartInventoryPool logic_level: Logic - shuffle_seals: PowerSeals shuffle_shards: MegaShards limited_movement: LimitedMovement early_meditation: EarlyMed @@ -159,4 +155,3 @@ class MessengerOptions(PerGameCommonOptions): shop_price: ShopPrices shop_price_plan: PlannedShopPrices death_link: DeathLink - diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index b13a453f7f59..3c39a7ba6da2 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -211,8 +211,6 @@ def has_windmill(self, state: CollectionState) -> bool: def set_messenger_rules(self) -> None: super().set_messenger_rules() for loc, rule in self.extra_rules.items(): - if not self.world.options.shuffle_seals and "Seal" in loc: - continue if not self.world.options.shuffle_shards and "Shard" in loc: continue add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or") @@ -253,16 +251,8 @@ def set_messenger_rules(self) -> None: def set_self_locking_items(world: "MessengerWorld", player: int) -> None: multiworld = world.multiworld - # do the ones for seal shuffle on and off first + # locations where these placements are always valid allow_self_locking_items(multiworld.get_location("Searing Crags - Key of Strength", player), "Power Thistle") allow_self_locking_items(multiworld.get_location("Sunken Shrine - Key of Love", player), "Sun Crest", "Moon Crest") allow_self_locking_items(multiworld.get_location("Corrupted Future - Key of Courage", player), "Demon King Crown") - - # add these locations when seals are shuffled - if world.options.shuffle_seals: - allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), "Currents Master") - # add these locations when seals and shards aren't shuffled - elif not world.options.shuffle_shards: - for entrance in multiworld.get_region("Cloud Ruins", player).entrances: - entrance.access_rule = lambda state: state.has("Wingsuit", player) or state.has("Rope Dart", player) - allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) + allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), "Currents Master") diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index b6a0b80b21a6..e8ebafce73af 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import Optional, TYPE_CHECKING, cast +from typing import Optional, TYPE_CHECKING from BaseClasses import CollectionState, Item, ItemClassification, Location, Region from .constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS @@ -24,10 +24,10 @@ def __init__(self, name: str, world: "MessengerWorld") -> None: MessengerLocation) elif self.name == "Tower HQ": locations.append("Money Wrench") - if world.options.shuffle_seals and self.name in SEALS: - locations += [seal_loc for seal_loc in SEALS[self.name]] + if self.name in SEALS: # from what bit of testing i did this is faster than get + locations += SEALS[self.name] if world.options.shuffle_shards and self.name in MEGA_SHARDS: - locations += [shard for shard in MEGA_SHARDS[self.name]] + locations += MEGA_SHARDS[self.name] loc_dict = {loc: world.location_name_to_id.get(loc, None) for loc in locations} self.add_locations(loc_dict, MessengerLocation) world.multiworld.regions.append(self) @@ -46,24 +46,26 @@ class MessengerShopLocation(MessengerLocation): @cached_property def cost(self) -> int: name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped - world = cast("MessengerWorld", self.parent_region.multiworld.worlds[self.player]) + world = self.parent_region.multiworld.worlds[self.player] + assert isinstance(world, MessengerWorld) shop_data = SHOP_ITEMS[name] if shop_data.prerequisite: prereq_cost = 0 if isinstance(shop_data.prerequisite, set): for prereq in shop_data.prerequisite: - prereq_cost +=\ - cast(MessengerShopLocation, - world.multiworld.get_location(prereq, self.player)).cost + loc = world.multiworld.get_location(prereq, self.player) + assert isinstance(loc, MessengerShopLocation) + prereq_cost += loc.cost else: - prereq_cost +=\ - cast(MessengerShopLocation, - world.multiworld.get_location(shop_data.prerequisite, self.player)).cost + loc = world.multiworld.get_location(shop_data.prerequisite, self.player) + assert isinstance(loc, MessengerShopLocation) + prereq_cost += loc.cost return world.shop_prices[name] + prereq_cost return world.shop_prices[name] def access_rule(self, state: CollectionState) -> bool: - world = cast("MessengerWorld", state.multiworld.worlds[self.player]) + world = state.multiworld.worlds[self.player] + assert isinstance(world, MessengerWorld) can_afford = state.has("Shards", self.player, min(self.cost, world.total_shards)) return can_afford diff --git a/worlds/messenger/test/test_logic.py b/worlds/messenger/test/test_logic.py index 7d958f7afc75..15df89b92097 100644 --- a/worlds/messenger/test/test_logic.py +++ b/worlds/messenger/test/test_logic.py @@ -1,8 +1,5 @@ -from typing import cast - from BaseClasses import ItemClassification from . import MessengerTestBase -from .. import Logic, MessengerWorld, PowerSeals class HardLogicTest(MessengerTestBase): From ff28793039484be5dfee5947b8a1d70d74629cba Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 7 Jan 2024 07:59:14 -0600 Subject: [PATCH 099/163] some cleanup stuff --- worlds/messenger/subclasses.py | 2 -- worlds/messenger/test/__init__.py | 8 ++++++- worlds/messenger/test/test_options.py | 8 +++---- worlds/messenger/test/test_shop.py | 8 +++---- worlds/messenger/test/test_shop_chest.py | 29 ++++++++++-------------- 5 files changed, 26 insertions(+), 29 deletions(-) diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index e8ebafce73af..9d1fb7b1c541 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -47,7 +47,6 @@ class MessengerShopLocation(MessengerLocation): def cost(self) -> int: name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped world = self.parent_region.multiworld.worlds[self.player] - assert isinstance(world, MessengerWorld) shop_data = SHOP_ITEMS[name] if shop_data.prerequisite: prereq_cost = 0 @@ -65,7 +64,6 @@ def cost(self) -> int: def access_rule(self, state: CollectionState) -> bool: world = state.multiworld.worlds[self.player] - assert isinstance(world, MessengerWorld) can_afford = state.has("Shards", self.player, min(self.cost, world.total_shards)) return can_afford diff --git a/worlds/messenger/test/__init__.py b/worlds/messenger/test/__init__.py index 7ab1e11781da..10e126c062c4 100644 --- a/worlds/messenger/test/__init__.py +++ b/worlds/messenger/test/__init__.py @@ -1,6 +1,12 @@ -from test.TestBase import WorldTestBase +from test.bases import WorldTestBase +from .. import MessengerWorld class MessengerTestBase(WorldTestBase): game = "The Messenger" player: int = 1 + world: MessengerWorld + + def setUp(self) -> None: + super().setUp() + self.world = self.multiworld.worlds[self.player] diff --git a/worlds/messenger/test/test_options.py b/worlds/messenger/test/test_options.py index 237f116a19f5..df426fed5602 100644 --- a/worlds/messenger/test/test_options.py +++ b/worlds/messenger/test/test_options.py @@ -4,13 +4,12 @@ from Fill import distribute_items_restrictive from . import MessengerTestBase from .. import MessengerWorld -from ..options import Logic, PowerSeals +from ..options import Logic class LimitedMovementTest(MessengerTestBase): options = { "limited_movement": "true", - "shuffle_seals": "false", "shuffle_shards": "true", } @@ -21,9 +20,8 @@ def run_default_tests(self) -> bool: def test_options(self) -> None: """Tests that options were correctly changed.""" - world = cast(MessengerWorld, self.multiworld.worlds[self.player]) - self.assertEqual(PowerSeals.option_true, world.options.shuffle_seals) - self.assertEqual(Logic.option_hard, world.options.logic_level) + assert isinstance(self.multiworld.worlds[self.player], MessengerWorld) + self.assertEqual(Logic.option_hard, self.world.options.logic_level) class EarlyMeditationTest(MessengerTestBase): diff --git a/worlds/messenger/test/test_shop.py b/worlds/messenger/test/test_shop.py index afb1b32b88e3..ee7e82d6cdbe 100644 --- a/worlds/messenger/test/test_shop.py +++ b/worlds/messenger/test/test_shop.py @@ -17,7 +17,7 @@ def test_shop_rules(self) -> None: self.assertFalse(self.can_reach_location(loc)) def test_shop_prices(self) -> None: - prices: Dict[str, int] = self.multiworld.worlds[self.player].shop_prices + prices: Dict[str, int] = self.world.shop_prices for loc, price in prices.items(): with self.subTest("prices", loc=loc): self.assertLessEqual(price, self.multiworld.get_location(f"The Shop - {loc}", self.player).cost) @@ -51,7 +51,7 @@ class ShopCostMinTest(ShopCostTest): } def test_shop_rules(self) -> None: - if self.multiworld.worlds[self.player].total_shards: + if self.world.total_shards: super().test_shop_rules() else: for loc in SHOP_ITEMS: @@ -85,7 +85,7 @@ def test_costs(self) -> None: with self.subTest("has cost", loc=loc): self.assertFalse(self.can_reach_location(loc)) - prices = self.multiworld.worlds[self.player].shop_prices + prices = self.world.shop_prices for loc, price in prices.items(): with self.subTest("prices", loc=loc): if loc == "Karuta Plates": @@ -98,7 +98,7 @@ def test_costs(self) -> None: self.assertTrue(loc.replace("The Shop - ", "") in SHOP_ITEMS) self.assertEqual(len(prices), len(SHOP_ITEMS)) - figures = self.multiworld.worlds[self.player].figurine_prices + figures = self.world.figurine_prices for loc, price in figures.items(): with self.subTest("figure prices", loc=loc): if loc == "Barmath'azel Figurine": diff --git a/worlds/messenger/test/test_shop_chest.py b/worlds/messenger/test/test_shop_chest.py index a34fa0fb96c0..ffa250214849 100644 --- a/worlds/messenger/test/test_shop_chest.py +++ b/worlds/messenger/test/test_shop_chest.py @@ -4,19 +4,14 @@ class AllSealsRequired(MessengerTestBase): options = { - "shuffle_seals": "false", "goal": "power_seal_hunt", } - def test_seals_shuffled(self) -> None: - """Shuffle seals should be forced on when shop chest is the goal so test it.""" - self.assertTrue(self.multiworld.shuffle_seals[self.player]) - def test_chest_access(self) -> None: """Defaults to a total of 45 power seals in the pool and required.""" with self.subTest("Access Dependency"): self.assertEqual(len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]), - self.multiworld.total_seals[self.player]) + self.world.options.total_seals) locations = ["Rescue Phantom"] items = [["Power Seal"]] self.assertAccessDependency(locations, items) @@ -40,9 +35,9 @@ class HalfSealsRequired(MessengerTestBase): def test_seals_amount(self) -> None: """Should have 45 power seals in the item pool and half that required""" - self.assertEqual(self.multiworld.total_seals[self.player], 45) - self.assertEqual(self.multiworld.worlds[self.player].total_seals, 45) - self.assertEqual(self.multiworld.worlds[self.player].required_seals, 22) + self.assertEqual(self.world.options.total_seals, 45) + self.assertEqual(self.world.total_seals, 45) + self.assertEqual(self.world.required_seals, 22) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] @@ -59,9 +54,9 @@ class ThirtyThirtySeals(MessengerTestBase): def test_seals_amount(self) -> None: """Should have 30 power seals in the pool and 33 percent of that required.""" - self.assertEqual(self.multiworld.total_seals[self.player], 30) - self.assertEqual(self.multiworld.worlds[self.player].total_seals, 30) - self.assertEqual(self.multiworld.worlds[self.player].required_seals, 10) + self.assertEqual(self.world.options.total_seals, 30) + self.assertEqual(self.world.total_seals, 30) + self.assertEqual(self.world.required_seals, 10) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] @@ -77,8 +72,8 @@ class MaxSealsNoShards(MessengerTestBase): def test_seals_amount(self) -> None: """Should set total seals to 70 since shards aren't shuffled.""" - self.assertEqual(self.multiworld.total_seals[self.player], 85) - self.assertEqual(self.multiworld.worlds[self.player].total_seals, 70) + self.assertEqual(self.world.options.total_seals, 85) + self.assertEqual(self.world.total_seals, 70) class MaxSealsWithShards(MessengerTestBase): @@ -90,9 +85,9 @@ class MaxSealsWithShards(MessengerTestBase): def test_seals_amount(self) -> None: """Should have 85 seals in the pool with all required and be a valid seed.""" - self.assertEqual(self.multiworld.total_seals[self.player], 85) - self.assertEqual(self.multiworld.worlds[self.player].total_seals, 85) - self.assertEqual(self.multiworld.worlds[self.player].required_seals, 85) + self.assertEqual(self.world.options.total_seals, 85) + self.assertEqual(self.world.total_seals, 85) + self.assertEqual(self.world.required_seals, 85) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] From f60c2f3e1fd4b9ad03d8226395f96661d2cc2861 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 11 Jan 2024 20:44:03 -0600 Subject: [PATCH 100/163] portal rando progress --- worlds/messenger/__init__.py | 16 +- worlds/messenger/connections.py | 96 ++++++++++++ worlds/messenger/options.py | 31 +++- worlds/messenger/portals.py | 259 ++++++++++++++++++++++++++++++++ worlds/messenger/regions.py | 255 ++++++++++++++++++++++++++++--- 5 files changed, 634 insertions(+), 23 deletions(-) create mode 100644 worlds/messenger/connections.py create mode 100644 worlds/messenger/portals.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 2e8b743a47c8..2e824671a264 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -8,7 +8,8 @@ from worlds.LauncherComponents import Component, Type, components from .client_setup import launch_game from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS -from .options import Goal, Logic, MessengerOptions, NotesNeeded +from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded +from .portals import SHUFFLEABLE_PORTAL_ENTRANCES, add_closed_portal_reqs, disconnect_portals, shuffle_portals from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices @@ -88,6 +89,8 @@ class MessengerWorld(World): shop_prices: Dict[str, int] figurine_prices: Dict[str, int] _filler_items: List[str] + starting_portals: List[str] + portal_mapping: List[int] def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: @@ -102,6 +105,11 @@ def generate_early(self) -> None: self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) + if self.options.available_portals > AvailablePortals.range_start: + # there's 3 specific portals that the game forces open + self.starting_portals = self.random.choices(SHUFFLEABLE_PORTAL_ENTRANCES, + k=self.options.available_portals - 3) + def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: @@ -174,6 +182,11 @@ def set_rules(self) -> None: MessengerHardRules(self).set_messenger_rules() else: MessengerOOBRules(self).set_messenger_rules() + add_closed_portal_reqs(self) + # i need ER to happen after rules exist so i can validate it + if self.options.shuffle_portals: + disconnect_portals(self) + shuffle_portals(self) def fill_slot_data(self) -> Dict[str, Any]: return { @@ -181,6 +194,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, "max_price": self.total_shards, "required_seals": self.required_seals, + "starting_portals": self.starting_portals, **self.options.as_dict("music_box", "death_link", "logic_level"), } diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py new file mode 100644 index 000000000000..61823e4d0864 --- /dev/null +++ b/worlds/messenger/connections.py @@ -0,0 +1,96 @@ +CONNECTIONS = { + "Ninja Village": { + "Right": { + "exits": [ + "Autumn Hills - Left", + ], + }, + }, + "Autumn Hills": { + "Left": { + "exits": [ + "Ninja Village - Right", + ], + }, + "Right": { + "exits": [ + "Forlorn Temple - Left", + ], + }, + "Bottom": { + "exits": [ + "Catacombs - Bottom Left", + ], + }, + "Portal": { + "exits": [ + "Tower HQ", + ], + }, + "Climbing Claws Shop": { + "exits": [ + "Autumn Hills - Left", + "Autumn Hills - Hope Path Shop", + ] + }, + "Hope Path Shop": { + "exits": [ + "Autumn Hills - Climbing Claws Shop", + "Autumn Hills - Hope Path Checkpoint", + "Autumn Hills - Lakeside Checkpoint", + ] + }, + "Dimension Climb Shop": { + "exits": [ + "Autumn Hills - Lakeside Checkpoint", + "Autumn Hills - Portal", + "Autumn Hills - Double Swing Checkpoint", + ] + }, + "Leaf Golem Shop": { + "exits": [ + "Autumn Hills - Spike Ball Swing Checkpoint", + "Autumn Hills - Right", + ] + }, + "Hope Path Checkpoint": { + "exits": [ + "Autumn Hills - Hope Path Shop", + "Autumn Hills - Key of Hope Checkpoint", + ] + }, + "Key of Hope Checkpoint": { + "exits": [ + "Autumn Hills - Hope Path Checkpoint", + "Autumn Hills - Lakeside Checkpoint", + ] + }, + "Lakeside Checkpoint": { + "exits": [ + "Autumn Hills - Hope Path Shop", + "Autumn Hills - Dimension Climb Shop", + ] + }, + "Double Swing Checkpoint": { + "exits": [ + "Autumn Hills - Dimension Climb Shop", + "Autumn Hills - Spike Ball Swing Checkpoint", + ] + }, + "Spike Ball Swing Checkpoint": { + "exits": [ + "Autumn Hills - Double Swing Checkpoint", + "Autumn Hills - Leaf Golem Shop", + ] + }, + }, + "Forlorn Temple": { + "Left": { + "exits": [ + "Autumn Hills - Right", + "Forlorn Temple - Outside Shop", + ] + } + } +} + diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 974c0331f02a..a16e38d9563e 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -4,7 +4,7 @@ from schema import And, Optional, Or, Schema from Options import Accessibility, Choice, DeathLink, DefaultOnToggle, OptionDict, PerGameCommonOptions, Range, \ - StartInventoryPool, Toggle + StartInventoryPool, TextChoice, Toggle class MessengerAccessibility(Accessibility): @@ -45,6 +45,33 @@ class EarlyMed(Toggle): display_name = "Early Meditation" +class AvailablePortals(Range): + """Number of portals that are available from the start. Autumn Hills, Howling Grotto, and Glacial Peak are currently always available. If portal outputs are not randomized, Searing Crags will also be available.""" + display_name = "Number of Available Starting Portals" + range_start = 3 + range_end = 6 + default = 4 + + +class ShufflePortals(TextChoice): + """ + Whether the portals lead to random places. + Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant. + Supports plando. + + None: Portals will take you where they're supposed to. + Shops: Portals can lead to any area except Music Box and Elemental Skylands, with each portal output guaranteed to not overlap with another portal's. Will only put you at a portal or a shop. + Checkpoints: Like Shops except checkpoints without shops are also valid drop points. + Anywhere: Like Shuffle except it's possible for multiple portals to output to the same map. + """ + display_name = "Shuffle Portal Outputs" + option_none = 0 + alias_off = 0 + option_shops = 1 + option_checkpoints = 2 + option_anywhere = 3 + + class Goal(Choice): """Requirement to finish the game.""" display_name = "Goal" @@ -147,6 +174,8 @@ class MessengerOptions(PerGameCommonOptions): shuffle_shards: MegaShards limited_movement: LimitedMovement early_meditation: EarlyMed + available_portals: AvailablePortals + shuffle_portals: ShufflePortals goal: Goal music_box: MusicBox notes_needed: NotesNeeded diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py new file mode 100644 index 000000000000..4c18dffe9aa2 --- /dev/null +++ b/worlds/messenger/portals.py @@ -0,0 +1,259 @@ +from typing import Dict, TYPE_CHECKING + +from BaseClasses import CollectionState +from .options import ShufflePortals + +if TYPE_CHECKING: + from . import MessengerWorld + + +SHUFFLEABLE_PORTAL_ENTRANCES = [ + "Riviere Turquoise Portal", + "Sunken Shrine Portal", + "Searing Crags Portal", +] + + +OUTPUT_PORTALS = [ + "Autumn Hills Portal", + "Riviere Turquoise Portal", + "Howling Grotto Portal", + "Sunken Shrine Portal", + "Searing Crags Portal", + "Glacial Peak Portal", +] + + +REGION_ORDER = [ + "Autumn Hills", + "Forlorn Temple", + "Catacombs", + "Bamboo Creek", + "Howling Grotto", + "Quillshroom Marsh", + "Searing Crags", + "Glacial Peak", + "Tower of Time", + "CloudRuins", + "Underworld", + "Riviere Turquoise", + "Sunken Shrine", +] + + +SHOP_POINTS = { + "Autumn Hills": [ + "Climbing Claws", + "Hope Path", + "Dimension Climb", + "Leaf Golem", + ], + "Forlorn Temple": [ + "Outside", + "Entrance", + "Climb", + "Rocket Sunset", + "Descent", + "Final Fall", + "Demon King", + ], + "Catacombs": [ + "Triple Spike Crushers", + "Ruxxtin", + ], + "Bamboo Creek": [ + "Spike Crushers", + "Abandoned", + "Time Loop", + ], + "Howling Grotto": [ + "Wingsuit", + "Crushing Pits", + "Emerald Golem", + ], + "Quillshroom Marsh": [ + "Spikey Window", + "Sand Trap", + "Queen of Quills", + ], + "Searing Crags": [ + "Rope Dart", + "Triple Ball Spinner", + "Searing Mega Shard", + "Before Final Climb", + "Colossuses", + "Key of Strength", + ], + "Glacial Peak": [ + "Ice Climbers'", + "Glacial Mega Shard", + "Tower Entrance", + ], + "Tower of Time": [ + "Entrance", + "Arcane Golem", + ], + "Cloud Ruins": [ + "Entrance", + "First Gap", + "Left Middle", + "Right Middle", + "Pre Acro", + "Pre Manfred", + ], + "Underworld": [ + "Left", + "Spike Wall", + "Middle", + "Right", + ], + "Riviere Turquoise": [ + "Pre Fairy", + "Pre Flower Pit", + "Pre Restock", + "Pre Ascension", + "Launch of Faith", + "Post Waterfall", + ], + "Sunken Shrine": [ + "Above Portal", + "Ultra Lifeguard", + "Sun Path", + "Tabi Gauntlet", + "Moon Path", + ] +} + + +CHECKPOINTS = { + "Autumn Hills": [ + "Hope Path", + "Key of Hope", + "Lakeside", + "Double Swing", + "Spike Ball Swing", + ], + "Forlorn Temple": [ + "Sunny Day", + "Rocket Maze", + ], + "Catacombs": [ + "Death Trap", + "Crusher Gauntlet", + "Dirty Pond", + ], + "Bamboo Creek": [ + "Spike Ball Pits", + "Spike Doors", + ], + "Howling Grotto": [ + "Lost Woods", + "Breezy Crushers", + ], + "Quillshroom Marsh": [ + "Seashell", + "Quicksand", + "Spike Wave", + ], + "Searing Crags": [ + "Triple Ball Spinner", + "Raining Rocks", + ], + "Glacial Peak": [ + "Projectile Spike Pit", + "Air Swag", + "Free Climbing", + ], + "Tower of Time": [ + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Sixth", + ], + "Cloud Ruins": [ + "Time Warp", + "Ghost Pit", + "Toothrush Alley", + "Saw Pit", + ], + "Underworld": [ + "Sharp Drop", + "Final Stretch", + "Hot Tub", + ], + "Riviere Turquoise": [ + "Water Logged", + ], + "Sunken Shrine": [ + "Ninja Tabi", + "Sun Crest", + "Waterfall Paradise", + "Moon Crest", + ] +} + + +def shuffle_portals(world: "MessengerWorld") -> None: + shuffle_type = world.options.shuffle_portals + shop_points = SHOP_POINTS.copy() + for portal in OUTPUT_PORTALS: + shop_points[portal].append(f"{portal} Portal") + if shuffle_type > ShufflePortals.option_shops: + shop_points.update(CHECKPOINTS) + out_to_parent = {checkpoint: parent for parent, checkpoints in shop_points.items() for checkpoint in checkpoints} + available_portals = list(shop_points.values()) + + world.portal_mapping = [] + for portal in OUTPUT_PORTALS: + warp_point = world.random.choice(available_portals) + parent = out_to_parent[warp_point] + exit_string = f"{parent.strip(' ')} - " + if "Portal" in warp_point: + exit_string += "Portal" + world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}00")) + elif warp_point in SHOP_POINTS[parent]: + exit_string += f"{warp_point} Shop" + world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}1{SHOP_POINTS[parent].index(warp_point)}")) + else: + exit_string += f"{warp_point} Checkpoint" + world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}2{CHECKPOINTS[parent].index(warp_point)}")) + connect_portal(world, portal, exit_string) + + available_portals.remove(warp_point) + if shuffle_type < ShufflePortals.option_anywhere: + available_portals -= shop_points[out_to_parent[warp_point]] + + if not validate_portals(world): + disconnect_portals(world) + shuffle_portals(world) + + +def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> None: + (world.multiworld.get_region("Tower HQ", world.player) + .connect(world.multiworld.get_region(out_region, world.player), portal)) + + +def disconnect_portals(world: "MessengerWorld") -> None: + for portal in OUTPUT_PORTALS: + entrance = world.multiworld.get_entrance(portal, world.player) + entrance.parent_region.exits.remove(entrance) + entrance.connected_region.exits.remove(entrance) + + +def validate_portals(world: "MessengerWorld") -> bool: + new_state = CollectionState(world.multiworld) + for loc in set(world.multiworld.get_locations(world.player)): + if loc.can_reach(new_state): + return True + return False + + +def add_closed_portal_reqs(world: "MessengerWorld") -> None: + closed_portals = [entrance for entrance in SHUFFLEABLE_PORTAL_ENTRANCES if entrance not in world.starting_portals] + if not closed_portals: + return + for portal in closed_portals: + tower_exit = world.multiworld.get_entrance(f"ToTHQ {portal}", world.player) + tower_exit.access_rule = lambda state: state.has(portal, world.player) diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 43de4dd1f6d0..e720ad3f50d8 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Set +from typing import Dict, List, Set, Union REGIONS: Dict[str, List[str]] = { "Menu": [], @@ -7,8 +7,10 @@ "The Craftsman's Corner": [], "Tower of Time": [], "Ninja Village": ["Ninja Village - Candle", "Ninja Village - Astral Seed"], + "Ninja Village - Right": [], "Autumn Hills": ["Autumn Hills - Climbing Claws", "Autumn Hills - Key of Hope", "Autumn Hills - Leaf Golem"], "Forlorn Temple": ["Forlorn Temple - Demon King"], + "Forlorn Temple Outside Shop": [], "Catacombs": ["Catacombs - Necro", "Catacombs - Ruxxtin's Amulet", "Catacombs - Ruxxtin"], "Bamboo Creek": ["Bamboo Creek - Claustro"], "Howling Grotto": ["Howling Grotto - Wingsuit", "Howling Grotto - Emerald Golem"], @@ -30,6 +32,174 @@ "Music Box": ["Rescue Phantom"], } + +SUB_REGIONS: Dict[str, List[str]] = { + "Ninja Village": [ + "Right", + ], + "Autumn Hills": [ + "Left", + "Right", + "Bottom", + "Portal", + "Climbing Claws Shop", + "Hope Path Shop", + "Dimension Climb Shop", + "Leaf Golem Shop", + "Hope Path Checkpoint", + "Key of Hope Checkpoint", + "Lakeside Checkpoint", + "Double Swing Checkpoint", + "Spike Ball Swing Checkpoint", + ], + "Forlorn Temple": [ + "Left", + "Right", + "Bottom", + "Outside Shop", + "Entrance Shop", + "Climb Shop", + "Rocket Sunset Shop", + "Descent Shop", + "Final Fall Shop", + "Demon King Shop", + "Sunny Day Checkpoint", + "Rocket Maze Checkpoint", + ], + "Catacombs": [ + "Top Left", + "Bottom Left", + "Bottom", + "Right", + "Triple Spike Crushers Shop", + "Ruxxtin Shop", + "Death Trap Checkpoint", + "Crusher Gauntlet Checkpoint", + "Dirty Pond Checkpoint", + ], + "Bamboo Creek": [ + "Bottom Left", + "Top Left", + "Right", + "Spike Crushers Shop", + "Abandoned Shop", + "Time Loop Shop", + "Spike Ball Pits Checkpoint", + "Spike Doors Checkpoint", + ], + "Howling Grotto": [ + "Left", + "Top", + "Right", + "Bottom", + "Portal", + "Wingsuit Shop", + "Crushing Pits Shop", + "Emerald Golem Shop", + "Lost Woods Checkpoint", + "Breezy Crushers Checkpoint", + ], + "Quillshroom Marsh": [ + "Top Left", + "Bottom Left", + "Top Right", + "Bottom Right", + "Spikey Window Shop", + "Sand Trap Shop", + "Queen of Quills Shop", + "Seashell Checkpoint", + "Quicksand Checkpoint", + "Spike Wave Checkpoint", + ], + "Searing Crags": [ + "Left", + "Top", + "Bottom", + "Right", + "Portal", + "Rope Dart Shop", + "Triple Ball Spinner Shop", + "Searing Mega Shard Shop", + "Before Final Climb Shop", + "Colossuses Shop", + "Key of Strength Shop", + "Triple Ball Spinner Checkpoint", + "Raining Rocks Checkpoint", + ], + "Glacial Peak": [ + "Bottom", + "Top", + "Portal", + "Ice Climbers' Shop", + "Glacial Mega Shard Shop", + "Tower Entrance Shop", + "Projectile Spike Pit Checkpoint", + "Air Swag Checkpoint", + "Free Climbing Checkpoint", + ], + "Tower of Time": [ + "Left", + "Entrance Shop", + "Arcane Golem Shop", + "First Checkpoint", + "Second Checkpoint", + "Third Checkpoint", + "Fourth Checkpoint", + "Fifth Checkpoint", + "Sixth Checkpoint", + ], + "Cloud Ruins": [ + "Left", + "Entrance Shop", + "First Gap Shop", + "Shop", + "Shop", + "Shop", + "Shop", + "Shop", + "Checkpoint", + "Checkpoint", + "Checkpoint", + "Checkpoint", + ], + "Underworld": [ + "Left", + "Shop", + "Shop", + "Shop", + "Shop", + "Shop", + "Checkpoint", + "Checkpoint", + "Checkpoint", + ], + "Riviere Turquoise": [ + "Right", + "Portal", + "Shop", + "Shop", + "Shop", + "Shop", + "Shop", + "Shop", + "Checkpoint", + ], + "Sunken Shrine": [ + "Left", + "Portal", + "Shop", + "Shop", + "Shop", + "Shop", + "Shop", + "Checkpoint", + "Checkpoint", + "Checkpoint", + "Checkpoint", + ] +} + + SEALS: Dict[str, List[str]] = { "Ninja Village": ["Ninja Village Seal - Tree House"], "Autumn Hills": ["Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", @@ -80,24 +250,67 @@ } -REGION_CONNECTIONS: Dict[str, Set[str]] = { - "Menu": {"Tower HQ"}, - "Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time", - "Riviere Turquoise Entrance", "Sunken Shrine", "Corrupted Future", "The Shop", - "The Craftsman's Corner", "Music Box"}, - "Autumn Hills": {"Ninja Village", "Forlorn Temple", "Catacombs"}, - "Forlorn Temple": {"Catacombs", "Bamboo Creek"}, - "Catacombs": {"Autumn Hills", "Bamboo Creek", "Dark Cave"}, - "Bamboo Creek": {"Catacombs", "Howling Grotto"}, - "Howling Grotto": {"Bamboo Creek", "Quillshroom Marsh", "Sunken Shrine"}, - "Quillshroom Marsh": {"Howling Grotto", "Searing Crags"}, - "Searing Crags": {"Searing Crags Upper", "Quillshroom Marsh", "Underworld"}, - "Searing Crags Upper": {"Searing Crags", "Glacial Peak"}, - "Glacial Peak": {"Searing Crags Upper", "Tower HQ", "Cloud Ruins", "Elemental Skylands"}, - "Cloud Ruins": {"Cloud Ruins Right"}, - "Cloud Ruins Right": {"Underworld"}, - "Dark Cave": {"Catacombs", "Riviere Turquoise Entrance"}, - "Riviere Turquoise Entrance": {"Riviere Turquoise"}, - "Sunken Shrine": {"Howling Grotto"}, +REGION_CONNECTIONS: Dict[str, Union[Dict[str, str], str]] = { + "Menu": {"Start Game": "Tower HQ"}, + "Tower HQ": { + "ToTHQ Autumn Hills Portal": "Autumn Hills Portal", + "ToTHQ Howling Grotto Portal": "Howling Grotto Portal", + "ToTHQ Searing Crags Portal": "Searing Crags Portal", + "ToTHQ Glacial Peak Portal": "Glacial Peak Portal", + "ToTHQ -> Tower of Time": "Tower of Time - Left", + "ToTHQ Riviere Turquoise Portal": "Riviere Turquoise Portal", + "ToTHQ Sunken Shrine Portal": "Sunken Shrine Portal", + "Artificer's Portal": "Corrupted Future", + "Home": "The Shop", + "Money Sink": "The Craftsman's Corner", + "Shrink Down": "Music Box", + }, + "Ninja Village - Right": "Autumn Hills", + "Autumn Hills - Left": "Ninja Village", + "Autumn Hills - Right": "Forlorn Temple", + "Autumn Hills - Bottom": "Catacombs", + "Autumn Hills Portal": "Tower HQ", + "Forlorn Temple - Left": "Autumn Hills", + "Forlorn Temple - Bottom": "Catacombs", + "Forlorn Temple - Right": "Bamboo Creek", + "Catacombs - Top Left": "Forlorn Temple", + "Catacombs - Bottom Left": "Autumn Hills", + "Catacombs - Bottom": "Dark Cave", + "Catacombs - Right": "Bamboo Creek", + "Dark Cave": { + "Dark Cave - Right": "Catacombs - Bottom", + "Dark Cave - Left": "Riviere Turquoise - Right", + }, + "Bamboo Creek - Bottom Left": "Catacombs - Right", + "Bamboo Creek - Top Left": "Forlorn Temple - Right", + "Bamboo Creek - Right": "Howling Grotto - Top", + "Howling Grotto - Left": "Bamboo Creek - Right", + "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", + "Howling Grotto - Right": "Quillshroom Marsh - Top Left", + "Howling Grotto - Bottom": "Sunken Shrine", + "Howling Grotto Portal": "Tower HQ", + "Quillshroom Marsh - Top Left": "Howling Grotto - Right", + "Quillshroom Marsh - Bottom Left": "Howling Grotto - Top", + "Quillshroom Marsh - Top Right": "Searing Crags - Left", + "Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom", + "Searing Crags - Left": "Quillshroom Marsh - Top Right", + "Searing Crags - Top": "Glacial Peak - Bottom", + "Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right", + "Searing Crags - Right": "Underworld - Left", + "Searing Crags Portal": "Tower HQ", + "Glacial Peak - Bottom": "Searing Crags - Top", + "Glacial Peak - Top": "Cloud Ruins - Left", + "Glacial Peak Portal": "Tower HQ", + "Cloud Ruins - Left": "Glacial Peak - Top", + "Underworld - Top Left": "Searing Crags - Right", + "Sunken Shrine - Left": "Howling Grotto - Bottom", + "Sunken Shrine Portal": "Tower HQ", + "Riviere Turquoise Portal": "Tower HQ", +} +"""Vanilla layout mapping with all Tower HQ portals open. format is source[entrance_name][exit_region] or source[exit_region]""" + + +IN_AREA_REGION_CONNECTIONS: Dict[str, Dict[str, str]] = { + } -"""Vanilla layout mapping with all Tower HQ portals open. from -> to""" +"""Vanilla layout mapping of sub region connections within the larger regions.""" From 7164d34c8be213cf982e2ac7bad6eb2eacaac623 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 16 Jan 2024 06:44:52 -0600 Subject: [PATCH 101/163] pre-emptive region creation --- worlds/messenger/__init__.py | 24 +- worlds/messenger/connections.py | 941 +++++++++++++++++++++++++++++++- worlds/messenger/portals.py | 2 +- worlds/messenger/regions.py | 412 ++++++++------ worlds/messenger/rules.py | 4 + worlds/messenger/subclasses.py | 43 +- 6 files changed, 1241 insertions(+), 185 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 2e824671a264..ed6918c357e3 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -7,11 +7,12 @@ from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components from .client_setup import launch_game +from .connections import CONNECTIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded from .portals import SHUFFLEABLE_PORTAL_ENTRANCES, add_closed_portal_reqs, disconnect_portals, shuffle_portals -from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS -from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules +from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS, SEALS +from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules, parse_rule from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerItem, MessengerRegion @@ -112,9 +113,22 @@ def generate_early(self) -> None: def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld - for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: - if region.name in REGION_CONNECTIONS: - region.add_exits(REGION_CONNECTIONS[region.name]) + # create and connect static connections + for region in [MessengerRegion(level, self) for level in LEVELS]: + region.add_exits(REGION_CONNECTIONS) + for reg_exit in region.exits: + rules = REGION_CONNECTIONS[region.name][reg_exit.name] + if isinstance(rules, dict): + for rule in rules.values(): + reg_exit.access_rule = parse_rule(rule, self.player) + # create and connect complex regions that have sub-regions + for region in [MessengerRegion(reg_name, self, parent) + for parent, sub_region in CONNECTIONS.items() + for reg_name in sub_region]: + connection_data = CONNECTIONS[region.parent][region.name] + for index, exit_name in enumerate(connection_data["exits"]): + region_exit = region.create_exit(exit_name) + region_exit.access_rule = parse_rule(connection_data["rules"][index], self.player) def create_items(self) -> None: # create items that are always in the item pool diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 61823e4d0864..9cce6afef4ce 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -1,9 +1,13 @@ -CONNECTIONS = { +from typing import Dict, List + + +CONNECTIONS: Dict[str, Dict[str, Dict[str, List[str]]]] = { "Ninja Village": { "Right": { "exits": [ "Autumn Hills - Left", ], + "rules": ["True"], }, }, "Autumn Hills": { @@ -11,6 +15,7 @@ "exits": [ "Ninja Village - Right", ], + "rules": ["True"], }, "Right": { "exits": [ @@ -21,67 +26,78 @@ "exits": [ "Catacombs - Bottom Left", ], + "rules": ["True"], }, "Portal": { "exits": [ "Tower HQ", ], + "rules": ["True"], }, "Climbing Claws Shop": { "exits": [ "Autumn Hills - Left", "Autumn Hills - Hope Path Shop", - ] + ], + "rules": ["True", "True"], }, "Hope Path Shop": { "exits": [ "Autumn Hills - Climbing Claws Shop", "Autumn Hills - Hope Path Checkpoint", "Autumn Hills - Lakeside Checkpoint", - ] + ], + "rules": ["True", "True", "True"], }, "Dimension Climb Shop": { "exits": [ "Autumn Hills - Lakeside Checkpoint", "Autumn Hills - Portal", "Autumn Hills - Double Swing Checkpoint", - ] + ], + "rules": ["True", "True", "True"], }, "Leaf Golem Shop": { "exits": [ "Autumn Hills - Spike Ball Swing Checkpoint", "Autumn Hills - Right", - ] + ], + "rules": ["True", "True"], }, "Hope Path Checkpoint": { "exits": [ "Autumn Hills - Hope Path Shop", "Autumn Hills - Key of Hope Checkpoint", - ] + ], + "rules": ["True", "True", "True"], }, "Key of Hope Checkpoint": { "exits": [ "Autumn Hills - Hope Path Checkpoint", "Autumn Hills - Lakeside Checkpoint", - ] + ], + "rules": ["True", "True", "True"], }, "Lakeside Checkpoint": { "exits": [ "Autumn Hills - Hope Path Shop", "Autumn Hills - Dimension Climb Shop", - ] + ], + "rules": ["True", "True", "True"], }, "Double Swing Checkpoint": { "exits": [ "Autumn Hills - Dimension Climb Shop", "Autumn Hills - Spike Ball Swing Checkpoint", - ] + ], + "rules": ["True", "True", "True"], }, "Spike Ball Swing Checkpoint": { "exits": [ "Autumn Hills - Double Swing Checkpoint", "Autumn Hills - Leaf Golem Shop", - ] + ], + "rules": ["True", "True", "True"], }, }, "Forlorn Temple": { @@ -89,8 +105,905 @@ "exits": [ "Autumn Hills - Right", "Forlorn Temple - Outside Shop", - ] - } - } + ], + "rules": ["True", "True", "True"], + }, + "Right": { + "exits": [ + "Bamboo Creek - Left", + "Forlorn Temple - Demon King Shop", + ], + "rules": ["True", "True", "True"], + }, + "Bottom": { + "exits": [ + "Catacombs - Top Left", + "Forlorn Temple - Outside Shop", + ], + "rules": ["True", "True", "True"], + }, + "Outside Shop": { + "exits": [ + "Forlorn Temple - Left", + "Forlorn Temple - Bottom", + "Forlorn Temple - Entrance Shop", + ], + "rules": ["True", "True", "True"], + }, + "Entrance Shop": { + "exits": [ + "Forlorn Temple - Outside Shop", + "Forlorn Temple - Sunny Dat Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Sunny Day Checkpoint": { + "exits": [ + "Forlorn Temple - Outside Shop", + "Forlorn Temple - Rocket Maze Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Rocket Maze Checkpoint": { + "exits": [ + "Forlorn Temple - Sunny Day Checkpoint", + "Forlorn Temple - Climb Shop", + ], + "rules": ["True", "True", "True"], + }, + "Climb Shop": { + "exits": [ + "Forlorn Temple - Rocket Maze Checkpoint", + "Forlorn Temple - Rocket Sunset Shop", + ], + "rules": ["True", "True", "True"], + }, + "Rocket Sunset Shop": { + "exits": [ + "Forlorn Temple - Climb Shop", + "Forlorn Temple - Descent Shop", + ], + "rules": ["True", "True", "True"], + }, + "Descent Shop": { + "exits": [ + "Forlorn Temple - Rocket Sunset Shop", + "Forlorn Temple - Final Fall Shop", + ], + "rules": ["True", "True", "True"], + }, + "Final Fall Shop": { + "exits": [ + "Forlorn Temple - Descent Shop", + "Forlorn Temple - Demon King Shop", + ], + "rules": ["True", "True", "True"], + }, + "Demon King Shop": { + "exits": [ + "Forlorn Temple - Final Fall Shop", + "Forlorn Temple - Right", + ], + "rules": ["True", "True", "True"], + }, + }, + "Catacombs": { + "Top Left": { + "exits": [ + "Forlorn Temple - Bottom", + "Catacombs - Triple Spike Crushers Shop", + ], + "rules": ["True", "True", "True"], + }, + "Bottom Left": { + "exits": [ + "Autumn Hills - Bottom", + "Catacombs - Triple Spike Crushers Shop", + "Catacombs - Death Trap Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Bottom": { + "exits": [ + "Dark Cave - Right", + "Catacombs - Dirty Pond Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Right": { + "exits": [ + "Bamboo Creek - Bottom Left", + "Catacombs - Ruxxtin Shop", + ], + "rules": ["True", "True", "True"], + }, + "Triple Spike Crushers Shop": { + "exits": [ + "Catacombs - Bottom Left", + "Catacombs - Death Trap Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Ruxxtin Shop": { + "exits": [ + "Catacombs - Right", + "Catacombs - Dirty Pond Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Death Trap Checkpoint": { + "exits": [ + "Catacombs - Triple Spike Crushers Shop", + "Catacombs - Bottom Left", + "Catacombs - Dirty Pond Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Crusher Gauntlet Checkpoint": { + "exits": [ + "Catacombs - Dirty Pond Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Dirty Pond Checkpoint": { + "exits": [ + "Catacombs - Bottom", + "Catacombs - Death Trap Checkpoint", + "Catacombs - Crusher Gauntlet Checkpoint", + "Catacombs - Ruxxtin Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Bamboo Creek": { + "Bottom Left": { + "exits": [ + "Catacombs - Right", + "Bamboo Creek - Spike Crushers Shop", + ], + "rules": ["True", "True", "True"], + }, + "Top Left": { + "exits": [ + "Forlorn Temple - Right", + "Bamboo Creek - Abandoned Shop", + ], + "rules": ["True", "True", "True"], + }, + "Right": { + "exits": [ + "Howling Grotto - Left", + "Bamboo Creek - Time Loop Shop", + ], + "rules": ["True", "True", "True"], + }, + "Spike Crushers Shop": { + "exits": [ + "Bamboo Creek - Bottom Left", + "Bamboo Creek - Abandoned Shop", + ], + "rules": ["True", "True", "True"], + }, + "Abandoned Shop": { + "exits": [ + "Bamboo Creek - Top Left", + "Bamboo Creek - Spike Crushers Shop", + "Bamboo Creek - Spike Doors Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Time Loop Shop": { + "exits": [ + "Bamboo Creek - Right", + "Bamboo Creek - Spike Doors Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Spike Ball Pits Checkpoint": { + "exits": [ + "Bamboo Creek - Spike Doors Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Spike Doors Checkpoint": { + "exits": [ + "Bamboo Creek - Abandoned Shop", + "Bamboo Creek - Spike Ball Pits Checkpoint", + "Bamboo Creek - Time Loop Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Howling Grotto": { + "Left": { + "exits": [ + "Bamboo Creek - Right", + "Howling Grotto - Wingsuit Shop", + ], + "rules": ["True", "True", "True"], + }, + "Top": { + "exits": [ + "Howling Grotto - Crushing Pits Shop", + "Quillshroom Marsh - Bottom Right", + ], + "rules": ["True", "True", "True"], + }, + "Right": { + "exits": [ + "Howling Grotto - Emerald Golem Shop", + "Quillshroom Marsh - Top Left", + ], + "rules": ["True", "True", "True"], + }, + "Bottom": { + "exits": [ + "Howling Grotto - Lost Woods Checkpoint", + "Sunken Shrine - Left", + ], + "rules": ["True", "True", "True"], + }, + "Portal": { + "exits": [ + "Howling Grotto - Crushing Pits Shop", + "Tower HQ", + ], + "rules": ["True", "True", "True"], + }, + "Wingsuit Shop": { + "exits": [ + "Howling Grotto - Left", + "Howling Grotto - Lost Woods Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Crushing Pits Shop": { + "exits": [ + "Howling Grotto - Lost Woods Checkpoint", + "Howling Grotto - Portal", + "Howling Grotto - Breezy Crushers Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Emerald Golem Shop": { + "exits": [ + "Howling Grotto - Breezy Crushers Checkpoint", + "Howling Grotto - Right", + ], + "rules": ["True", "True", "True"], + }, + "Lost Woods Checkpoint": { + "exits": [ + "Howling Grotto - Wingsuit Shop", + "Howling Grotto - Crushing Pits Shop", + ], + "rules": ["True", "True", "True"], + }, + "Breezy Crushers Checkpoint": { + "exits": [ + "Howling Grotto - Crushing Pits Shop", + "Howling Grotto - Emerald Golem Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Quillshroom Marsh": { + "Top Left": { + "exits": [ + "Howling Grotto - Right", + "Quillshroom Marsh - Seashell Checkpoint", + "Quillshroom Marsh - Spikey Window Shop", + ], + "rules": ["True", "True", "True"], + }, + "Bottom Left": { + "exits": [ + "Howling Grotto - Top", + "Quillshroom Marsh - Sand Trap Shop", + "Quillshroom Marsh - Bottom Right", + ], + "rules": ["True", "True", "True"], + }, + "Top Right": { + "exits": [ + "Quillshroom Marsh - Queen of Quills Shop", + "Searing Crags - Left", + ], + "rules": ["True", "True", "True"], + }, + "Bottom Right": { + "exits": [ + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Sand Trap Shop", + "Searing Crags - Bottom", + ], + "rules": ["True", "True", "True"], + }, + "Spikey Window Shop": { + "exits": [ + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Seashell Checkpoint", + "Quillshroom Marsh - Quicksand Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Sand Trap Shop": { + "exits": [ + "Quillshroom Marsh - Quicksand Checkpoint", + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Bottom Right", + "Quillshroom Marsh - Spike Wave Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Queen of Quills Shop": { + "exits": [ + "Quillshroom Marsh - Spike Wave Checkpoint", + "Quillshroom Marsh - Top Right", + ], + "rules": ["True", "True", "True"], + }, + "Seashell Checkpoint": { + "exits": [ + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Spikey Window Shop", + ], + "rules": ["True", "True", "True"], + }, + "Quicksand Checkpoint": { + "exits": [ + "Quillshroom Marsh - Spikey Window Shop", + "Quillshroom Marsh - Sand Trap Shop", + ], + "rules": ["True", "True", "True"], + }, + "Spike Wave Checkpoint": { + "exits": [ + "Quillshroom Marsh - Sand Trap Shop", + "Quillshroom Marsh - Queen of Quills Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Searing Crags": { + "Left": { + "exits": [ + "Quillshroom Marsh - Top Right", + "Searing Crags - Rope Dart Shop", + ], + "rules": ["True", "True", "True"], + }, + "Top": { + "exits": [ + "Searing Crags - Colossuses Shop", + "Glacial Peak - Bottom", + ], + "rules": ["True", "True", "True"], + }, + "Bottom": { + "exits": [ + "Searing Crags - Portal", + "Quillshroom Marsh - Bottom Right", + ], + "rules": ["True", "True", "True"], + }, + "Right": { + "exits": [ + "Searing Crags - Portal", + "Underworld - Left", + ], + "rules": ["True", "True", "True"], + }, + "Portal": { + "exits": [ + "Searing Crags - Bottom", + "Searing Crags - Right", + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Colossuses Shop", + "Tower HQ", + ], + "rules": ["True", "True", "True"], + }, + "Rope Dart Shop": { + "exits": [ + "Searing Crags - Left", + "Searing Crags - Triple Ball Spinner Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Triple Ball Spinner Shop": { + "exits": [ + "Searing Crags - Triple Ball Spinner Checkpoint", + "Searing Crags - Searing Mega Shard Shop", + ], + "rules": ["True", "True", "True"], + }, + "Searing Mega Shard Shop": { + "exits": [ + "Searing Crags - Triple Ball Spinner Shop", + "Searing Crags - Raining Rocks Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Before Final Climb Shop": { + "exits": [ + "Searing Crags - Raining Rocks Checkpoint", + "Searing Crags - Portal", + "Searing Crags - Colossuses Shop", + ], + "rules": ["True", "True", "True"], + }, + "Colossuses Shop": { + "exits": [ + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Key of Strength Shop", + "Searing Crags - Portal", + "Searing Crags - Top", + ], + "rules": ["True", "True", "True"], + }, + "Key of Strength Shop": { + "exits": [ + "Searing Crags - Searing Mega Shard Shop", + ], + "rules": ["True", "True", "True"], + }, + "Triple Ball Spinner Checkpoint": { + "exits": [ + "Searing Crags - Rope Dart Shop", + "Searing Crags - Triple Ball Spinner Shop", + ], + "rules": ["True", "True", "True"], + }, + "Raining Rocks Checkpoint": { + "exits": [ + "Searing Crags - Searing Mega Shard Shop", + "Searing Crags - Before Final Climb Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Glacial Peak": { + "Bottom": { + "exits": [ + "Searing Crags - Top", + "Ice Climbers' Shop", + ], + "rules": ["True", "True", "True"], + }, + "Left": { + "exits": [ + "Elemental Skylands", + "Glacial Peak - Projectile Spike Pit Checkpoint", + "Glacial Peak - Glacial Mega Shard Shop", + ], + "rules": ["True", "True", "True"], + }, + "Top": { + "exits": [ + "Tower Entrance Shop", + "Cloud Ruins - Left", + "Glacial Peak - Portal", + ], + "rules": ["True", "True", "True"], + }, + "Portal": { + "exits": [ + "Glacial Peak - Top", + "Tower HQ", + ], + "rules": ["True", "True", "True"], + }, + "Ice Climbers' Shop": { + "exits": [ + "Glacial Peak - Bottom", + "Glacial Peak - Projectile Spike Pit Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Glacial Mega Shard Shop": { + "exits": [ + "Glacial Peak - Left", + "Glacial Peak - Air Swag Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Tower Entrance Shop": { + "exits": [ + "Glacial Peak - Top", + "Glacial Peak - Free Climbing Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Projectile Spike Pit Checkpoint": { + "exits": [ + "Glacial Peak - Ice Climbers' Shop", + "Glacial Peak - Left", + ], + "rules": ["True", "True", "True"], + }, + "Air Swag Checkpoint": { + "exits": [ + "Glacial Peak - Glacial Mega Shard Shop", + "Glacial Peak - Free Climbing Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Free Climbing Checkpoint": { + "exits": [ + "Glacial Peak - Air Swag Checkpoint", + "Glacial Peak - Tower Entrance Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Tower of Time": { + "Left": { + "exits": [ + "Tower of Time - Entrance Shop", + ], + "rules": ["True", "True", "True"], + }, + "Entrance Shop": { + "exits": [ + "Tower of Time - First Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Arcane Golem Shop": { + "exits": [ + "Tower HQ", + "Sixth Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "First Checkpoint": { + "exits": [ + "Tower of Time - Entrance Shop", + "Tower of Time - Second Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Second Checkpoint": { + "exits": [ + "Tower of Time - First Checkpoint", + "Tower of Time - Third Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Third Checkpoint": { + "exits": [ + "Tower of Time - Second Checkpoint", + "Tower of Time - Fourth Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Fourth Checkpoint": { + "exits": [ + "Tower of Time - Third Checkpoint", + "Tower of Time - Fifth Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Fifth Checkpoint": { + "exits": [ + "Tower of Time - Fourth Checkpoint", + "Tower of Time - Sixth Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Sixth Checkpoint": { + "exits": [ + "Tower of Time - Fifth Checkpoint", + "Tower of Time - Arcane Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Cloud Ruins": { + "Left": { + "exits": [ + "Glacial Peak - Top", + "Cloud Ruins - Entrance Shop", + ], + "rules": ["True", "True", "True"], + }, + "Entrance Shop": { + "exits": [ + "Cloud Ruins - Left", + "Cloud Ruins - Spike Float Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Pillar Glide Shop": { + "exits": [ + "Cloud Ruins - Spike Float Checkpoint", + "Cloud Ruins - Crushers' Descent Shop", + ], + "rules": ["True", "True", "True"], + }, + "Crushers' Descent Shop": { + "exits": [ + "Cloud Ruins - Pillar Glide Shop", + "Cloud Ruins - Toothbrush Alley Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Seeing Spikes Shop": { + "exits": [ + "Cloud Ruins - Toothbrush Alley Checkpoint", + "Cloud Ruins - Sliding Spikes Shop", + ], + "rules": ["True", "True", "True"], + }, + "Sliding Spikes Shop": { + "exits": [ + "Cloud Ruins - Seeing Spikes Shop", + "Cloud Ruins - Saw Pit Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Final Flight Shop": { + "exits": [ + "Cloud Ruins - Saw Pit Checkpoint", + "Cloud Ruins - Manfred's Shop", + ], + "rules": ["True", "True", "True"], + }, + "Manfred's Shop": { + "exits": [ + "Cloud Ruins - Final Flight Shop", + ], + "rules": ["True", "True", "True"], + }, + "Spike Float Checkpoint": { + "exits": [ + "Cloud Ruins - Entrance Shop", + "Cloud Ruins - Pillar Glide Shop", + ], + "rules": ["True", "True", "True"], + }, + "Ghost Pit Checkpoint": { + "exits": [ + "Cloud Ruins - Spike Float Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Toothbrush Alley Checkpoint": { + "exits": [ + "Cloud Ruins - Crushers' Descent Shop", + "Cloud Ruins - Seeing Spikes Shop", + ], + "rules": ["True", "True", "True"], + }, + "Saw Pit Checkpoint": { + "exits": [ + "Cloud Ruins - Sliding Spikes Shop", + "Cloud Ruins - Final Flight Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Underworld": { + "Left": { + "exits": [ + "Underworld - Entrance Shop", + "Searing Crags - Right", + ], + "rules": ["True", "True", "True"], + }, + "Entrance Shop": { + "exits": [ + "Underworld - Left", + "Underworld - Hot Dip Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Fireball Wave Shop": { + "exits": [ + "Underworld - Hot Dip Checkpoint", + "Underworld - Long Climb Shop", + ], + "rules": ["True", "True", "True"], + }, + "Long Climb Shop": { + "exits": [ + "Underworld - Fireball Wave Shop", + "Underworld - Hot Tub Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Barm'athaziel Shop": { + "exits": [ + "Underworld - Hot Tub Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Key of Chaos Shop": { + "exits": [ + "Underworld - Lava Run Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Hot Dip Checkpoint": { + "exits": [ + "Underworld - Entrance Shop", + "Underworld - Fireball Wave Shop", + "Underworld - Lava Run Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Hot Tub Checkpoint": { + "exits": [ + "Underworld - Long Climb Shop", + "Underworld - Barm'athaziel Shop", + ], + "rules": ["True", "True", "True"], + }, + "Lava Run Checkpoint": { + "exits": [ + "Underworld - Hot Dip Checkpoint", + "Underworld - Key of Chaos Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Dark Cave": { + "Right": { + "exits": [ + "Catacombs - Bottom", + ], + "rules": ["True", "True", "True"], + }, + "Left": { + "exits": [ + "Riviere Turquoise - Right", + ], + "rules": ["True", "True", "True"], + }, + }, + "Riviere Turquoise": { + "Right": { + "exits": [ + "Riviere Turquoise - Portal", + ], + "rules": ["True", "True", "True"], + }, + "Portal": { + "exits": [ + "Riviere Turquoise - Waterfall Shop", + "Tower HQ", + ], + "rules": ["True", "True", "True"], + }, + "Waterfall Shop": { + "exits": [ + "Riviere Turquoise - Portal", + "Riviere Turquoise - Flower Flight Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Launch of Faith Shop": { + "exits": [ + "Riviere Turquoise - Flower Flight Checkpoint", + "Riviere Turquoise - Log Flume Shop", + ], + "rules": ["True", "True", "True"], + }, + "Log Flume Shop": { + "exits": [ + "Riviere Turquoise - Launch of Faith Shop", + "Riviere Turquoise - Log Climb Shop", + ], + "rules": ["True", "True", "True"], + }, + "Log Climb Shop": { + "exits": [ + "Riviere Turquoise - Log Flume Shop", + "Riviere Turquoise - Restock Shop", + ], + "rules": ["True", "True", "True"], + }, + "Restock Shop": { + "exits": [ + "Riviere Turquoise - Log Climb Shop", + "Riviere Turquoise - Butterfly Matriarch Shop", + ], + "rules": ["True", "True", "True"], + }, + "Butterfly Matriarch Shop": { + "exits": [ + "Riviere Turquoise - Restock Shop", + ], + "rules": ["True", "True", "True"], + }, + "Flower Flight Checkpoint": { + "exits": [ + "Riviere Turquoise - Waterfall Shop", + "Riviere Turquoise - Launch of Faith Shop", + ], + "rules": ["True", "True", "True"], + }, + }, + "Sunken Shrine": { + "Left": { + "exits": [ + "Howling Grotto - Bottom", + "Sunken Shrine - Portal", + ], + "rules": ["True", "True", "True"], + }, + "Portal": { + "exits": [ + "Sunken Shrine - Left", + "Sunken Shrine - Entrance Shop", + "Sunken Shrine - Sun Path Shop", + "Sunken Shrine - Moon Path Shop", + "Tower HQ", + ], + "rules": ["True", "True", "True"], + }, + "Entrance Shop": { + "exits": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Lifeguard Shop", + ], + "rules": ["True", "True", "True"], + }, + "Lifeguard Shop": { + "exits": [ + "Sunken Shrine - Entrance Shop", + "Sunken Shrine - Lightfoot Tabi Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Sun Path Shop": { + "exits": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Tabi Gauntlet Shop", + ], + "rules": ["True", "True", "True"], + }, + "Tabi Gauntlet Shop": { + "exits": [ + "Sunken Shrine - Sun Path Shop", + "Sunken Shrine - Sun Crest Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Moon Path Shop": { + "exits": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Waterfall Paradise Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Lightfoot Tabi Checkpoint": { + "exits": [ + "Sunken Shrine - Portal", + ], + "rules": ["True", "True", "True"], + }, + "Sun Crest Checkpoint": { + "exits": [ + "Sunken Shrine - Tabi Gauntlet Shop", + "Sunken Shrine - Portal", + ], + "rules": ["True", "True", "True"], + }, + "Waterfall Paradise Checkpoint": { + "exits": [ + "Sunken Shrine - Moon Path Shop", + "Sunken Shrine - Moon Crest Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Moon Crest Checkpoint": { + "exits": [ + "Sunken Shrine - Waterfall Paradise Checkpoint", + "Sunken Shrine - Portal", + ], + "rules": ["True", "True", "True"], + }, + }, } - diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 4c18dffe9aa2..3921f21e817e 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -187,7 +187,7 @@ "Water Logged", ], "Sunken Shrine": [ - "Ninja Tabi", + "Lightfoot Tabi", "Sun Crest", "Waterfall Paradise", "Moon Crest", diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index e720ad3f50d8..b9950b71e432 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -1,33 +1,191 @@ -from typing import Dict, List, Set, Union +from typing import Dict, List, Union -REGIONS: Dict[str, List[str]] = { - "Menu": [], - "Tower HQ": [], - "The Shop": [], - "The Craftsman's Corner": [], - "Tower of Time": [], - "Ninja Village": ["Ninja Village - Candle", "Ninja Village - Astral Seed"], - "Ninja Village - Right": [], - "Autumn Hills": ["Autumn Hills - Climbing Claws", "Autumn Hills - Key of Hope", "Autumn Hills - Leaf Golem"], - "Forlorn Temple": ["Forlorn Temple - Demon King"], - "Forlorn Temple Outside Shop": [], - "Catacombs": ["Catacombs - Necro", "Catacombs - Ruxxtin's Amulet", "Catacombs - Ruxxtin"], - "Bamboo Creek": ["Bamboo Creek - Claustro"], - "Howling Grotto": ["Howling Grotto - Wingsuit", "Howling Grotto - Emerald Golem"], - "Quillshroom Marsh": ["Quillshroom Marsh - Seashell", "Quillshroom Marsh - Queen of Quills"], - "Searing Crags": ["Searing Crags - Rope Dart"], - "Searing Crags Upper": ["Searing Crags - Power Thistle", "Searing Crags - Key of Strength", - "Searing Crags - Astral Tea Leaves"], - "Glacial Peak": [], - "Cloud Ruins": [], - "Cloud Ruins Right": ["Cloud Ruins - Acro"], - "Underworld": ["Searing Crags - Pyro", "Underworld - Key of Chaos"], - "Dark Cave": [], - "Riviere Turquoise Entrance": [], - "Riviere Turquoise": ["Riviere Turquoise - Butterfly Matriarch"], - "Sunken Shrine": ["Sunken Shrine - Lightfoot Tabi", "Sunken Shrine - Sun Crest", "Sunken Shrine - Moon Crest", - "Sunken Shrine - Key of Love"], - "Elemental Skylands": ["Elemental Skylands - Key of Symbiosis"], + +LOCATIONS: Dict[str, List[str]] = { + "Ninja Village": [ + "Ninja Village - Candle", + "Ninja Village - Astral Seed" + ], + "Autumn Hills - Climbing Claws Shop": [ + "Autumn Hills - Climbing Claws", + "Autumn Hills Seal - Trip Saws", + ], + "Autumn Hills - Key of Hope Checkpoint": [ + "Autumn Hills - Key of Hope", + ], + "Autumn Hills - Double Swing Checkpoint": [ + "Autumn Hills Seal - Double Swing Saws", + ], + "Autumn Hills - Spike Ball Swing Checkpoint": [ + "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", + ], + "Autumn Hills - Leaf Golem Shop": [ + "Autumn Hills - Leaf Golem", + ], + "Forlorn Temple - Rocket Maze Checkpoint": [ + "Forlorn Temple Seal - Rocket Maze", + ], + "Forlorn Temple - Rocket Sunset Shop": [ + "Forlorn Temple Seal - Rocket Sunset", + ], + "Forlorn Temple - Demon King Shop": [ + "Forlorn Temple - Demon King", + ], + "Catacombs - Top Left": [ + "Catacombs - Necro", + ], + "Catacombs - Triple Spike Crushers Shop": [ + "Catacombs Seal - Triple Spike Crushers", + ], + "Catacombs - Dirty Pond Checkpoint": [ + "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", + ], + "Catacombs - Ruxxtin Shop": [ + "Catacombs - Ruxxtin's Amulet", + "Catacombs - Ruxxtin", + ], + "Bamboo Creek - Spike Crushers Shop": [ + "Bamboo Creek Seal - Spike Crushers and Doors", + ], + "Bamboo Creek - Spike Ball Pits Checkpoint": [ + "Bamboo Creek Seal - Spike Ball Pits", + ], + "Bamboo Creek - Time Loop Shop": [ + "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Bamboo Creek - Claustro", + ], + "Howling Grotto - Wingsuit Shop": [ + "Howling Grotto - Wingsuit", + "Howling Grotto Seal - Windy Saws and Balls", + ], + "Howling Grotto - Crushing Pits Shop": [ + "Howling Grotto Seal - Crushing Pits", + ], + "Howling Grotto - Breezy Crushers Checkpoint": [ + "Howling Grotto Seal - Breezy Crushers", + ], + "Howling Grotto - Emerald Golem Shop": [ + "Howling Grotto - Emerald Golem", + ], + "Quillshroom Marsh - Seashell Checkpoint": [ + "Quillshroom Marsh - Seashell", + ], + "Quillshroom Marsh - Spikey Window Shop": [ + "Quillshroom Marsh Seal - Spikey Window", + ], + "Quillshroom Marsh - Sand Trap Shop": [ + "Quillshroom Marsh Seal - Sand Trap", + ], + "Quillshroom Marsh - Spike Wave Checkpoint": [ + "Quillshroom Marsh Seal - Do the Spike Wave", + ], + "Quillshroom Marsh - Queen of Quills Shop": [ + "Quillshroom Marsh - Queen of Quills", + ], + "Searing Crags - Rope Dart Shop": [ + "Searing Crags - Rope Dart", + ], + "Searing Crags - Triple Ball Spinner Checkpoint": [ + "Searing Crags Seal - Triple Ball Spinner", + ], + "Searing Crags - Raining Rocks Checkpoint": [ + "Searing Crags Seal - Raining Rocks", + ], + "Searing Crags - Colossuses Shop": [ + "Searing Crags Seal - Rhythm Rocks", + "Searing Crags - Power Thistle", + "Searing Crags - Astral Tea Leaves", + ], + "Searing Crags - Key of Strength Shop": [ + "Searing Crags - Key of Strength", + ], + "Searing Crags - Right": [ + "Searing Crags - Pyro", + ], + "Glacial Peak - Ice Climbers' Shop": [ + "Glacial Peak Seal - Ice Climbers", + ], + "Glacial Peak - Projectile Spike Pit Checkpoint": [ + "Glacial Peak Seal - Projectile Spike Pit", + ], + "Glacial Peak - Air Swag Checkpoint": [ + "Glacial Peak Seal - Glacial Air Swag", + ], + "Tower of Time - First Checkpoint": [ + "Tower of Time Seal - Time Waster Seal", + ], + "Tower of Time - Third Checkpoint": [ + "Tower of Time Seal - Lantern Climb", + ], + "Tower of Time - Fifth Checkpoint": [ + "Tower of Time Seal - Arcane Orbs", + ], + "Cloud Ruins - Ghost Pit Checkpoint": [ + "Cloud Ruins Seal - Ghost Pit", + ], + "Cloud Ruins - Toothbrush Alley Checkpoint": [ + "Cloud Ruins Seal - Toothbrush Alley", + ], + "Cloud Ruins - Saw Pit Checkpoint": [ + "Cloud Ruins Seal - Saw Pit", + ], + "Cloud Ruins - Final Flight Checkpoint": [ + "Cloud Ruins - Acro", + ], + "Underworld - Entrance Shop": [ + "Underworld Seal - Sharp and Windy Climb", + ], + "Underworld - Fireball Wave Shop": [ + "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", + ], + "Underworld - Hot Tub Checkpoint": [ + "Underworld Seal - Rising Fanta", + ], + "Underworld - Key of Chaos Shop": [ + "Underworld - Key of Chaos", + ], + "Riviere Turquoise - Waterfall Shop": [ + "Riviere Turquoise Seal - Bounces and Balls", + ], + "Riviere Turquoise - Launch of Faith Shop": [ + "Riviere Turquoise Seal - Launch of Faith", + ], + "Riviere Turquoise - Restock Shop": [ + "Riviere Turquoise Seal - Flower Power", + ], + "Riviere Turquoise - Butterfly Matriarch Shop": [ + "Riviere Turquoise - Butterfly Matriarch", + ], + "Sunken Shrine - Lifeguard Shop": [ + "Sunken Shrine Seal - Ultra Lifeguard", + ], + "Sunken Shrine - Lightfoot Tabi Checkpoint": [ + "Sunken Shrine - Lightfoot Tabi", + ], + "Sunken Shrine Portal": [ + "Sunken Shrine - Key of Love", + ], + "Sunken Shrine - Tabi Gauntlet Shop": [ + "Sunken Shrine Seal - Tabi Gauntlet", + ], + "Sunken Shrine - Sun Crest Checkpoint": [ + "Sunken Shrine - Sun Crest", + ], + "Sunken Shrine - Waterfall Paradise Checkpoint": [ + "Sunken Shrine Seal - Waterfall Paradise", + ], + "Sunken Shrine - Moon Crest Checkpoint": [ + "Sunken Shrine - Moon Crest", + ], + "Elemental Skylands": [ + "Elemental Skylands Seal - Air", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire", + "Elemental Skylands - Key of Symbiosis", + ], "Corrupted Future": ["Corrupted Future - Key of Courage"], "Music Box": ["Rescue Phantom"], } @@ -151,101 +309,80 @@ "Cloud Ruins": [ "Left", "Entrance Shop", - "First Gap Shop", - "Shop", - "Shop", - "Shop", - "Shop", - "Shop", - "Checkpoint", - "Checkpoint", - "Checkpoint", - "Checkpoint", + "Pillar Glide Shop", + "Crushers' Descent Shop", + "Seeing Spikes Shop", + "Sliding Spikes Shop", + "Final Flight Shop", + "Manfred's Shop", + "Spike Float Checkpoint", + "Ghost Pit Checkpoint", + "Toothbrush Alley Checkpoint", + "Saw Pit Checkpoint", ], "Underworld": [ "Left", - "Shop", - "Shop", - "Shop", - "Shop", - "Shop", - "Checkpoint", - "Checkpoint", - "Checkpoint", + "Entrance Shop", + "Fireball Wave Shop", + "Long Climb Shop", + "Barm'athaziel Shop", + "Key of Chaos Shop", + "Hot Dip Checkpoint", + "Hot Tub Checkpoint", + "Lava Run Checkpoint", ], "Riviere Turquoise": [ "Right", "Portal", - "Shop", - "Shop", - "Shop", - "Shop", - "Shop", - "Shop", - "Checkpoint", + "Waterfall Shop", + "Launch of Faith Shop", + "Log Flume Shop", + "Log Climb Shop", + "Restock Shop", + "Butterfly Matriarch Shop", + "Flower Flight Checkpoint", ], "Sunken Shrine": [ "Left", "Portal", - "Shop", - "Shop", - "Shop", - "Shop", - "Shop", - "Checkpoint", - "Checkpoint", - "Checkpoint", - "Checkpoint", - ] + "Entrance Shop", + "Lifeguard Shop", + "Sun Path Shop", + "Tabi Gauntlet Shop", + "Moon Path Shop", + "Ninja Tabi Checkpoint", + "Sun Crest Checkpoint", + "Waterfall Paradise Checkpoint", + "Moon Crest Checkpoint", + ], } -SEALS: Dict[str, List[str]] = { - "Ninja Village": ["Ninja Village Seal - Tree House"], - "Autumn Hills": ["Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", - "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts"], - "Catacombs": ["Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", - "Catacombs Seal - Dirty Pond"], - "Bamboo Creek": ["Bamboo Creek Seal - Spike Crushers and Doors", "Bamboo Creek Seal - Spike Ball Pits", - "Bamboo Creek Seal - Spike Crushers and Doors v2"], - "Howling Grotto": ["Howling Grotto Seal - Windy Saws and Balls", "Howling Grotto Seal - Crushing Pits", - "Howling Grotto Seal - Breezy Crushers"], - "Quillshroom Marsh": ["Quillshroom Marsh Seal - Spikey Window", "Quillshroom Marsh Seal - Sand Trap", - "Quillshroom Marsh Seal - Do the Spike Wave"], - "Searing Crags": ["Searing Crags Seal - Triple Ball Spinner"], - "Searing Crags Upper": ["Searing Crags Seal - Raining Rocks", "Searing Crags Seal - Rhythm Rocks"], - "Glacial Peak": ["Glacial Peak Seal - Ice Climbers", "Glacial Peak Seal - Projectile Spike Pit", - "Glacial Peak Seal - Glacial Air Swag"], - "Tower of Time": ["Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb", - "Tower of Time Seal - Arcane Orbs"], - "Cloud Ruins Right": ["Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", - "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"], - "Underworld": ["Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall", - "Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta"], - "Forlorn Temple": ["Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset"], - "Sunken Shrine": ["Sunken Shrine Seal - Ultra Lifeguard", "Sunken Shrine Seal - Waterfall Paradise", - "Sunken Shrine Seal - Tabi Gauntlet"], - "Riviere Turquoise Entrance": ["Riviere Turquoise Seal - Bounces and Balls"], - "Riviere Turquoise": ["Riviere Turquoise Seal - Launch of Faith", "Riviere Turquoise Seal - Flower Power"], - "Elemental Skylands": ["Elemental Skylands Seal - Air", "Elemental Skylands Seal - Water", - "Elemental Skylands Seal - Fire"] -} - MEGA_SHARDS: Dict[str, List[str]] = { - "Autumn Hills": ["Autumn Hills Mega Shard", "Hidden Entrance Mega Shard"], - "Catacombs": ["Catacombs Mega Shard"], - "Bamboo Creek": ["Above Entrance Mega Shard", "Abandoned Mega Shard", "Time Loop Mega Shard"], - "Howling Grotto": ["Bottom Left Mega Shard", "Near Portal Mega Shard", "Pie in the Sky Mega Shard"], - "Quillshroom Marsh": ["Quillshroom Marsh Mega Shard"], - "Searing Crags Upper": ["Searing Crags Mega Shard"], - "Glacial Peak": ["Glacial Peak Mega Shard"], - "Cloud Ruins": ["Cloud Entrance Mega Shard", "Time Warp Mega Shard"], - "Cloud Ruins Right": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], - "Underworld": ["Under Entrance Mega Shard", "Hot Tub Mega Shard", "Projectile Pit Mega Shard"], - "Forlorn Temple": ["Sunny Day Mega Shard", "Down Under Mega Shard"], - "Sunken Shrine": ["Mega Shard of the Moon", "Beginner's Mega Shard", "Mega Shard of the Stars", "Mega Shard of the Sun"], - "Riviere Turquoise Entrance": ["Waterfall Mega Shard"], - "Riviere Turquoise": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], + "Autumn Hills - Lakeside Checkpoint": ["Autumn Hills Mega Shard"], + "Forlorn Temple - Outside Shop": ["Hidden Entrance Mega Shard"], + "Forlorn Temple - Sunny Day Checkpoint": ["Sunny Day Mega Shard"], + "Forlorn Temple - Demon King Shop": ["Down Under Mega Shard"], + "Catacombs - Top Left": ["Catacombs Mega Shard"], + "Bamboo Creek - Spike Crushers Shop": ["Above Entrance Mega Shard"], + "Bamboo Creek - Abandoned Shop": ["Abandoned Mega Shard"], + "Bamboo Creek - Time Loop Shop": ["Time Loop Mega Shard"], + "Howling Grotto - Lost Woods Checkpoint": ["Bottom Left Mega Shard"], + "Howling Grotto - Breezy Crushers Checkpoint": ["Near Portal Mega Shard", "Pie in the Sky Mega Shard"], + "Quillshroom Marsh - Spikey Window Shop": ["Quillshroom Marsh Mega Shard"], + "Searing Crags - Searing Mega Shard Shop": ["Searing Crags Mega Shard"], + "Glacial Peak - Glacial Mega Shard Shop": ["Glacial Peak Mega Shard"], + "Cloud Ruins - Entrance Shop": ["Cloud Entrance Mega Shard"], + "Cloud Ruins - Spike Float Checkpoint": ["Time Warp Mega Shard"], + "Cloud Ruins - Manfred's Shop": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], + "Underworld - Entrance Shop": ["Under Entrance Mega Shard"], + "Underworld - Hot Tub Checkpoint": ["Hot Tub Mega Shard", "Projectile Pit Mega Shard"], + "Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"], + "Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], + "Sunken Shrine - Entrance Shop": ["Mega Shard of the Stars"], + "Sunken Shrine - Portal": ["Beginner's Mega Shard"], + "Sunken Shrine - Sun Crest Checkpoint": ["Mega Shard of the Sun"], + "Sunken Shrine - Waterfall Shop": ["Mega Shard of the Moon"], "Elemental Skylands": ["Earth Mega Shard", "Water Mega Shard"], } @@ -262,55 +399,22 @@ "ToTHQ Sunken Shrine Portal": "Sunken Shrine Portal", "Artificer's Portal": "Corrupted Future", "Home": "The Shop", - "Money Sink": "The Craftsman's Corner", "Shrink Down": "Music Box", }, - "Ninja Village - Right": "Autumn Hills", - "Autumn Hills - Left": "Ninja Village", - "Autumn Hills - Right": "Forlorn Temple", - "Autumn Hills - Bottom": "Catacombs", - "Autumn Hills Portal": "Tower HQ", - "Forlorn Temple - Left": "Autumn Hills", - "Forlorn Temple - Bottom": "Catacombs", - "Forlorn Temple - Right": "Bamboo Creek", - "Catacombs - Top Left": "Forlorn Temple", - "Catacombs - Bottom Left": "Autumn Hills", - "Catacombs - Bottom": "Dark Cave", - "Catacombs - Right": "Bamboo Creek", - "Dark Cave": { - "Dark Cave - Right": "Catacombs - Bottom", - "Dark Cave - Left": "Riviere Turquoise - Right", + "The Shop": { + "Money Sink": "The Craftsman's Corner", }, - "Bamboo Creek - Bottom Left": "Catacombs - Right", - "Bamboo Creek - Top Left": "Forlorn Temple - Right", - "Bamboo Creek - Right": "Howling Grotto - Top", - "Howling Grotto - Left": "Bamboo Creek - Right", - "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", - "Howling Grotto - Right": "Quillshroom Marsh - Top Left", - "Howling Grotto - Bottom": "Sunken Shrine", - "Howling Grotto Portal": "Tower HQ", - "Quillshroom Marsh - Top Left": "Howling Grotto - Right", - "Quillshroom Marsh - Bottom Left": "Howling Grotto - Top", - "Quillshroom Marsh - Top Right": "Searing Crags - Left", - "Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom", - "Searing Crags - Left": "Quillshroom Marsh - Top Right", - "Searing Crags - Top": "Glacial Peak - Bottom", - "Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right", - "Searing Crags - Right": "Underworld - Left", - "Searing Crags Portal": "Tower HQ", - "Glacial Peak - Bottom": "Searing Crags - Top", - "Glacial Peak - Top": "Cloud Ruins - Left", - "Glacial Peak Portal": "Tower HQ", - "Cloud Ruins - Left": "Glacial Peak - Top", - "Underworld - Top Left": "Searing Crags - Right", - "Sunken Shrine - Left": "Howling Grotto - Bottom", - "Sunken Shrine Portal": "Tower HQ", - "Riviere Turquoise Portal": "Tower HQ", } """Vanilla layout mapping with all Tower HQ portals open. format is source[entrance_name][exit_region] or source[exit_region]""" -IN_AREA_REGION_CONNECTIONS: Dict[str, Dict[str, str]] = { - -} -"""Vanilla layout mapping of sub region connections within the larger regions.""" +# regions that don't have sub-regions and their exits +LEVELS: List[str] = [ + "Menu", + "Tower HQ", + "The Shop", + "The Craftsman's Corner", + "Elemental Skylands", + "Corrupted Future", + "Music Box", +] diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 3c39a7ba6da2..82d62849f5ef 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -248,6 +248,10 @@ def set_messenger_rules(self) -> None: self.world.options.accessibility.value = MessengerAccessibility.option_minimal +def parse_rule(rule_string: str, player: int) -> CollectionRule: + return lambda state: True + + def set_self_locking_items(world: "MessengerWorld", player: int) -> None: multiworld = world.multiworld diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 9d1fb7b1c541..85c98485eec6 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -2,8 +2,10 @@ from typing import Optional, TYPE_CHECKING from BaseClasses import CollectionState, Item, ItemClassification, Location, Region +from .connections import CONNECTIONS from .constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -from .regions import MEGA_SHARDS, REGIONS, SEALS +from .regions import MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS +from .rules import parse_rule from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS if TYPE_CHECKING: @@ -11,25 +13,44 @@ class MessengerRegion(Region): - - def __init__(self, name: str, world: "MessengerWorld") -> None: + parent: str + + def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = None) -> None: super().__init__(name, world.player, world.multiworld) - locations = [loc for loc in REGIONS[self.name]] - if self.name == "The Shop": + self.parent = parent + locations = [] + if name in LOCATIONS: + locations = [loc for loc in LOCATIONS[name]] + + if name == "The Shop": shop_locations = {f"The Shop - {shop_loc}": world.location_name_to_id[f"The Shop - {shop_loc}"] for shop_loc in SHOP_ITEMS} self.add_locations(shop_locations, MessengerShopLocation) - elif self.name == "The Craftsman's Corner": + elif name == "The Craftsman's Corner": self.add_locations({figurine: world.location_name_to_id[figurine] for figurine in FIGURINES}, MessengerLocation) - elif self.name == "Tower HQ": + elif name == "Tower HQ": locations.append("Money Wrench") - if self.name in SEALS: # from what bit of testing i did this is faster than get - locations += SEALS[self.name] - if world.options.shuffle_shards and self.name in MEGA_SHARDS: - locations += MEGA_SHARDS[self.name] + + if world.options.shuffle_shards and name in MEGA_SHARDS: + locations += MEGA_SHARDS[name] loc_dict = {loc: world.location_name_to_id.get(loc, None) for loc in locations} self.add_locations(loc_dict, MessengerLocation) + + if parent and name in CONNECTIONS[parent]: + region_data = CONNECTIONS[parent][name] + for index, exit_name in enumerate(region_data["exits"]): + reg_exit = self.create_exit(exit_name) + reg_exit.access_rule = parse_rule(region_data["rules"][index], self.player) + elif name in REGION_CONNECTIONS: + for exit_name, connecting_region in REGION_CONNECTIONS[name].items(): + if isinstance(connecting_region, dict): + for rule in connecting_region.values(): + reg_exit = self.create_exit(exit_name) + reg_exit.access_rule = parse_rule(rule, self.player) + else: + self.create_exit(exit_name) + world.multiworld.regions.append(self) From 676bac9ec1d76d1202b57167c0cc370cfbeb102e Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 16 Jan 2024 06:53:26 -0600 Subject: [PATCH 102/163] seals need to be in the datapackage --- worlds/messenger/__init__.py | 16 ++++++------- worlds/messenger/constants.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index ed6918c357e3..d239c7a50045 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -11,7 +11,7 @@ from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded from .portals import SHUFFLEABLE_PORTAL_ENTRANCES, add_closed_portal_reqs, disconnect_portals, shuffle_portals -from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS, SEALS +from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules, parse_rule from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerItem, MessengerRegion @@ -189,13 +189,13 @@ def create_items(self) -> None: self.multiworld.itempool += filler def set_rules(self) -> None: - logic = self.options.logic_level - if logic == Logic.option_normal: - MessengerRules(self).set_messenger_rules() - elif logic == Logic.option_hard: - MessengerHardRules(self).set_messenger_rules() - else: - MessengerOOBRules(self).set_messenger_rules() + # logic = self.options.logic_level + # if logic == Logic.option_normal: + # MessengerRules(self).set_messenger_rules() + # elif logic == Logic.option_hard: + # MessengerHardRules(self).set_messenger_rules() + # else: + # MessengerOOBRules(self).set_messenger_rules() add_closed_portal_reqs(self) # i need ER to happen after rules exist so i can validate it if self.options.shuffle_portals: diff --git a/worlds/messenger/constants.py b/worlds/messenger/constants.py index f05d276ceaf4..fa42e07856b6 100644 --- a/worlds/messenger/constants.py +++ b/worlds/messenger/constants.py @@ -103,6 +103,51 @@ "Searing Crags - Pyro", "Bamboo Creek - Claustro", "Cloud Ruins - Acro", + # seals + "Ninja Village Seal - Tree House", + "Autumn Hills Seal - Trip Saws", + "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", + "Catacombs Seal - Triple Spike Crushers", + "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", + "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", + "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Windy Saws and Balls", + "Howling Grotto Seal - Crushing Pits", + "Howling Grotto Seal - Breezy Crushers", + "Quillshroom Marsh Seal - Spikey Window", + "Quillshroom Marsh Seal - Sand Trap", + "Quillshroom Marsh Seal - Do the Spike Wave", + "Searing Crags Seal - Triple Ball Spinner", + "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", + "Glacial Peak Seal - Ice Climbers", + "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", + "Tower of Time Seal - Time Waster", + "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", + "Cloud Ruins Seal - Money Farm Room", + "Underworld Seal - Sharp and Windy Climb", + "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", + "Underworld Seal - Rising Fanta", + "Forlorn Temple Seal - Rocket Maze", + "Forlorn Templ Seal - Rocket Sunset", + "Sunken Shrine Seal - Ultra Lifeguard", + "Sunken Shrine Seal - Waterfall Paradise", + "Sunken Shrine Seal - Tabi Gauntlet", + "Riviere Turquoise Seal - Bounces and Balls", + "Riviere Turquoise Seal - Flower Power", + "Elemental Skylands Seal - Air", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire", ] BOSS_LOCATIONS = [ From a5369bb08d61da2503ea3d49bb21a0acdff83203 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 16 Jan 2024 06:56:20 -0600 Subject: [PATCH 103/163] put mega shards in old order --- worlds/messenger/__init__.py | 1 - worlds/messenger/regions.py | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index d239c7a50045..512bab130d99 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -65,7 +65,6 @@ class MessengerWorld(World): for location_id, location in enumerate([ *ALWAYS_LOCATIONS, - *[seal for seals in SEALS.values() for seal in seals], *[shard for shards in MEGA_SHARDS.values() for shard in shards], *BOSS_LOCATIONS, *[f"The Shop - {shop_loc}" for shop_loc in SHOP_ITEMS], diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index b9950b71e432..89500a82a221 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -358,11 +358,10 @@ } +# order is slightly funky here for back compat MEGA_SHARDS: Dict[str, List[str]] = { "Autumn Hills - Lakeside Checkpoint": ["Autumn Hills Mega Shard"], "Forlorn Temple - Outside Shop": ["Hidden Entrance Mega Shard"], - "Forlorn Temple - Sunny Day Checkpoint": ["Sunny Day Mega Shard"], - "Forlorn Temple - Demon King Shop": ["Down Under Mega Shard"], "Catacombs - Top Left": ["Catacombs Mega Shard"], "Bamboo Creek - Spike Crushers Shop": ["Above Entrance Mega Shard"], "Bamboo Creek - Abandoned Shop": ["Abandoned Mega Shard"], @@ -377,12 +376,14 @@ "Cloud Ruins - Manfred's Shop": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], "Underworld - Entrance Shop": ["Under Entrance Mega Shard"], "Underworld - Hot Tub Checkpoint": ["Hot Tub Mega Shard", "Projectile Pit Mega Shard"], - "Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"], - "Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], - "Sunken Shrine - Entrance Shop": ["Mega Shard of the Stars"], + "Forlorn Temple - Sunny Day Checkpoint": ["Sunny Day Mega Shard"], + "Forlorn Temple - Demon King Shop": ["Down Under Mega Shard"], + "Sunken Shrine - Waterfall Shop": ["Mega Shard of the Moon"], "Sunken Shrine - Portal": ["Beginner's Mega Shard"], + "Sunken Shrine - Entrance Shop": ["Mega Shard of the Stars"], "Sunken Shrine - Sun Crest Checkpoint": ["Mega Shard of the Sun"], - "Sunken Shrine - Waterfall Shop": ["Mega Shard of the Moon"], + "Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"], + "Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], "Elemental Skylands": ["Earth Mega Shard", "Water Mega Shard"], } From 44fd0dfe472d01b7a1ab6ed90fd9781f908f8984 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 16 Jan 2024 07:27:54 -0600 Subject: [PATCH 104/163] fix typos and make it actually work --- worlds/messenger/__init__.py | 29 +++++++++++++++++------------ worlds/messenger/connections.py | 23 ++++++++++++----------- worlds/messenger/regions.py | 26 +++++++++++++------------- worlds/messenger/subclasses.py | 16 +--------------- 4 files changed, 43 insertions(+), 51 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 512bab130d99..88c966fd911e 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -112,22 +112,27 @@ def generate_early(self) -> None: def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld - # create and connect static connections - for region in [MessengerRegion(level, self) for level in LEVELS]: - region.add_exits(REGION_CONNECTIONS) - for reg_exit in region.exits: - rules = REGION_CONNECTIONS[region.name][reg_exit.name] - if isinstance(rules, dict): - for rule in rules.values(): - reg_exit.access_rule = parse_rule(rule, self.player) + # create simple regions + for level in LEVELS: + MessengerRegion(level, self) # create and connect complex regions that have sub-regions - for region in [MessengerRegion(reg_name, self, parent) + for region in [MessengerRegion(f"{parent} - {reg_name}", self, parent) for parent, sub_region in CONNECTIONS.items() for reg_name in sub_region]: - connection_data = CONNECTIONS[region.parent][region.name] - for index, exit_name in enumerate(connection_data["exits"]): - region_exit = region.create_exit(exit_name) + region_name = region.name.replace(f"{region.parent} - ", "") + connection_data = CONNECTIONS[region.parent][region_name] + for index, exit_region in enumerate(connection_data["exits"]): + region_exit = region.connect(self.multiworld.get_region(exit_region, self.player)) region_exit.access_rule = parse_rule(connection_data["rules"][index], self.player) + # all regions need to be created before i can do these connections so we create and connect the complex first + for region_name in [level for level in LEVELS if level in REGION_CONNECTIONS]: + region = self.multiworld.get_region(region_name, self.player) + region.add_exits(REGION_CONNECTIONS[region.name]) + for reg_exit in region.exits: + rules = REGION_CONNECTIONS[region.name][reg_exit.connected_region.name] + if isinstance(rules, dict): + for rule in rules.values(): + reg_exit.access_rule = parse_rule(rule, self.player) def create_items(self) -> None: # create items that are always in the item pool diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 9cce6afef4ce..a9ca17b637fe 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -21,6 +21,7 @@ "exits": [ "Forlorn Temple - Left", ], + "rules": ["True"], }, "Bottom": { "exits": [ @@ -110,7 +111,7 @@ }, "Right": { "exits": [ - "Bamboo Creek - Left", + "Bamboo Creek - Top Left", "Forlorn Temple - Demon King Shop", ], "rules": ["True", "True", "True"], @@ -133,7 +134,7 @@ "Entrance Shop": { "exits": [ "Forlorn Temple - Outside Shop", - "Forlorn Temple - Sunny Dat Checkpoint", + "Forlorn Temple - Sunny Day Checkpoint", ], "rules": ["True", "True", "True"], }, @@ -252,7 +253,7 @@ "Catacombs - Crusher Gauntlet Checkpoint", "Catacombs - Ruxxtin Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True", "True", "True"], }, }, "Bamboo Creek": { @@ -434,7 +435,7 @@ "Quillshroom Marsh - Bottom Right", "Quillshroom Marsh - Spike Wave Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True", "True", "True"], }, "Queen of Quills Shop": { "exits": [ @@ -502,7 +503,7 @@ "Searing Crags - Colossuses Shop", "Tower HQ", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True", "True", "True", "True"], }, "Rope Dart Shop": { "exits": [ @@ -540,7 +541,7 @@ "Searing Crags - Portal", "Searing Crags - Top", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True", "True", "True"], }, "Key of Strength Shop": { "exits": [ @@ -567,7 +568,7 @@ "Bottom": { "exits": [ "Searing Crags - Top", - "Ice Climbers' Shop", + "Glacial Peak - Ice Climbers' Shop", ], "rules": ["True", "True", "True"], }, @@ -581,7 +582,7 @@ }, "Top": { "exits": [ - "Tower Entrance Shop", + "Glacial Peak - Tower Entrance Shop", "Cloud Ruins - Left", "Glacial Peak - Portal", ], @@ -653,7 +654,7 @@ "Arcane Golem Shop": { "exits": [ "Tower HQ", - "Sixth Checkpoint", + "Tower of Time - Sixth Checkpoint", ], "rules": ["True", "True", "True"], }, @@ -695,7 +696,7 @@ "Sixth Checkpoint": { "exits": [ "Tower of Time - Fifth Checkpoint", - "Tower of Time - Arcane Shop", + "Tower of Time - Arcane Golem Shop", ], "rules": ["True", "True", "True"], }, @@ -941,7 +942,7 @@ "Sunken Shrine - Moon Path Shop", "Tower HQ", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True", "True", "True", "True"], }, "Entrance Shop": { "exits": [ diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 89500a82a221..0a46326e312e 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -165,7 +165,7 @@ "Sunken Shrine - Lightfoot Tabi Checkpoint": [ "Sunken Shrine - Lightfoot Tabi", ], - "Sunken Shrine Portal": [ + "Sunken Shrine - Portal": [ "Sunken Shrine - Key of Love", ], "Sunken Shrine - Tabi Gauntlet Shop": [ @@ -389,21 +389,21 @@ REGION_CONNECTIONS: Dict[str, Union[Dict[str, str], str]] = { - "Menu": {"Start Game": "Tower HQ"}, + "Menu": {"Tower HQ": "Start Game"}, "Tower HQ": { - "ToTHQ Autumn Hills Portal": "Autumn Hills Portal", - "ToTHQ Howling Grotto Portal": "Howling Grotto Portal", - "ToTHQ Searing Crags Portal": "Searing Crags Portal", - "ToTHQ Glacial Peak Portal": "Glacial Peak Portal", - "ToTHQ -> Tower of Time": "Tower of Time - Left", - "ToTHQ Riviere Turquoise Portal": "Riviere Turquoise Portal", - "ToTHQ Sunken Shrine Portal": "Sunken Shrine Portal", - "Artificer's Portal": "Corrupted Future", - "Home": "The Shop", - "Shrink Down": "Music Box", + "Autumn Hills - Portal": "ToTHQ Autumn Hills Portal", + "Howling Grotto - Portal": "ToTHQ Howling Grotto Portal", + "Searing Crags - Portal": "ToTHQ Searing Crags Portal", + "Glacial Peak - Portal": "ToTHQ Glacial Peak Portal", + "Tower of Time - Left": "ToTHQ -> Tower of Time", + "Riviere Turquoise - Portal": "ToTHQ Riviere Turquoise Portal", + "Sunken Shrine - Portal": "ToTHQ Sunken Shrine Portal", + "Corrupted Future": "Artificer's Portal", + "The Shop": "Home", + "Music Box": "Shrink Down", }, "The Shop": { - "Money Sink": "The Craftsman's Corner", + "The Craftsman's Corner": "Money Sink", }, } """Vanilla layout mapping with all Tower HQ portals open. format is source[entrance_name][exit_region] or source[exit_region]""" diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 85c98485eec6..005b76ddf917 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -37,21 +37,7 @@ def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = N loc_dict = {loc: world.location_name_to_id.get(loc, None) for loc in locations} self.add_locations(loc_dict, MessengerLocation) - if parent and name in CONNECTIONS[parent]: - region_data = CONNECTIONS[parent][name] - for index, exit_name in enumerate(region_data["exits"]): - reg_exit = self.create_exit(exit_name) - reg_exit.access_rule = parse_rule(region_data["rules"][index], self.player) - elif name in REGION_CONNECTIONS: - for exit_name, connecting_region in REGION_CONNECTIONS[name].items(): - if isinstance(connecting_region, dict): - for rule in connecting_region.values(): - reg_exit = self.create_exit(exit_name) - reg_exit.access_rule = parse_rule(rule, self.player) - else: - self.create_exit(exit_name) - - world.multiworld.regions.append(self) + self.multiworld.regions.append(self) class MessengerLocation(Location): From a2abae1342271462a4200e189ce114f784f1feff Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 16 Jan 2024 07:53:23 -0600 Subject: [PATCH 105/163] fix more missed connections and add portal events --- worlds/messenger/connections.py | 9 +++++++-- worlds/messenger/constants.py | 4 ++-- worlds/messenger/subclasses.py | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index a9ca17b637fe..73bcc5179757 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -20,8 +20,9 @@ "Right": { "exits": [ "Forlorn Temple - Left", + "Autumn Hills - Leaf Golem Shop", ], - "rules": ["True"], + "rules": ["True", "True"], }, "Bottom": { "exits": [ @@ -32,8 +33,9 @@ "Portal": { "exits": [ "Tower HQ", + "Autumn Hills - Dimension Climb Shop", ], - "rules": ["True"], + "rules": ["True", "True"], }, "Climbing Claws Shop": { "exits": [ @@ -377,6 +379,7 @@ "exits": [ "Howling Grotto - Wingsuit Shop", "Howling Grotto - Crushing Pits Shop", + "Howling Grotto - Bottom", ], "rules": ["True", "True", "True"], }, @@ -719,6 +722,7 @@ "Pillar Glide Shop": { "exits": [ "Cloud Ruins - Spike Float Checkpoint", + "Cloud Ruins - Ghost Pit Checkpoint", "Cloud Ruins - Crushers' Descent Shop", ], "rules": ["True", "True", "True"], @@ -853,6 +857,7 @@ "Right": { "exits": [ "Catacombs - Bottom", + "Dark Cave - Left", ], "rules": ["True", "True", "True"], }, diff --git a/worlds/messenger/constants.py b/worlds/messenger/constants.py index fa42e07856b6..92379e3064c1 100644 --- a/worlds/messenger/constants.py +++ b/worlds/messenger/constants.py @@ -127,7 +127,7 @@ "Glacial Peak Seal - Ice Climbers", "Glacial Peak Seal - Projectile Spike Pit", "Glacial Peak Seal - Glacial Air Swag", - "Tower of Time Seal - Time Waster", + "Tower of Time Seal - Time Waster Seal", "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", "Cloud Ruins Seal - Ghost Pit", @@ -139,7 +139,7 @@ "Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta", "Forlorn Temple Seal - Rocket Maze", - "Forlorn Templ Seal - Rocket Sunset", + "Forlorn Temple Seal - Rocket Sunset", "Sunken Shrine Seal - Ultra Lifeguard", "Sunken Shrine Seal - Waterfall Paradise", "Sunken Shrine Seal - Tabi Gauntlet", diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 005b76ddf917..23449c06b8a0 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -21,6 +21,9 @@ def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = N locations = [] if name in LOCATIONS: locations = [loc for loc in LOCATIONS[name]] + # portal event locations since portals can be opened from their exit regions + if "Portal" in name: + locations.append(name.replace(" -", "")) if name == "The Shop": shop_locations = {f"The Shop - {shop_loc}": world.location_name_to_id[f"The Shop - {shop_loc}"] From dbbbc9ff91666db390a26d093341779c01c339d0 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 16 Jan 2024 23:09:23 -0600 Subject: [PATCH 106/163] fix all the portal rando code --- worlds/messenger/__init__.py | 7 +++++-- worlds/messenger/connections.py | 2 +- worlds/messenger/options.py | 1 + worlds/messenger/portals.py | 23 +++++++++++----------- worlds/messenger/rules.py | 35 +++++++++++++++++++++++++++++---- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 88c966fd911e..2dab6121aff8 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -123,7 +123,7 @@ def create_regions(self) -> None: connection_data = CONNECTIONS[region.parent][region_name] for index, exit_region in enumerate(connection_data["exits"]): region_exit = region.connect(self.multiworld.get_region(exit_region, self.player)) - region_exit.access_rule = parse_rule(connection_data["rules"][index], self.player) + region_exit.access_rule = parse_rule(connection_data["rules"][index], self.player, self.options.logic_level.value) # all regions need to be created before i can do these connections so we create and connect the complex first for region_name in [level for level in LEVELS if level in REGION_CONNECTIONS]: region = self.multiworld.get_region(region_name, self.player) @@ -207,14 +207,17 @@ def set_rules(self) -> None: shuffle_portals(self) def fill_slot_data(self) -> Dict[str, Any]: - return { + slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, "max_price": self.total_shards, "required_seals": self.required_seals, "starting_portals": self.starting_portals, + "portal_mapping": self.portal_mapping if self.portal_mapping else [], **self.options.as_dict("music_box", "death_link", "logic_level"), } + print(slot_data) + return slot_data def get_filler_item_name(self) -> str: if not getattr(self, "_filler_items", None): diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 73bcc5179757..76b7cbc6e53d 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -35,7 +35,7 @@ "Tower HQ", "Autumn Hills - Dimension Climb Shop", ], - "rules": ["True", "True"], + "rules": ["True", "Wingsuit, Rope Dart"], }, "Climbing Claws Shop": { "exits": [ diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index a16e38d9563e..aae546e29c9a 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -70,6 +70,7 @@ class ShufflePortals(TextChoice): option_shops = 1 option_checkpoints = 2 option_anywhere = 3 + default = 2 class Goal(Choice): diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 3921f21e817e..5008d7376b7d 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -15,12 +15,12 @@ OUTPUT_PORTALS = [ - "Autumn Hills Portal", - "Riviere Turquoise Portal", - "Howling Grotto Portal", - "Sunken Shrine Portal", - "Searing Crags Portal", - "Glacial Peak Portal", + "Autumn Hills", + "Riviere Turquoise", + "Howling Grotto", + "Sunken Shrine", + "Searing Crags", + "Glacial Peak", ] @@ -203,7 +203,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: if shuffle_type > ShufflePortals.option_shops: shop_points.update(CHECKPOINTS) out_to_parent = {checkpoint: parent for parent, checkpoints in shop_points.items() for checkpoint in checkpoints} - available_portals = list(shop_points.values()) + available_portals = [val for zone in shop_points.values() for val in zone] world.portal_mapping = [] for portal in OUTPUT_PORTALS: @@ -223,7 +223,8 @@ def shuffle_portals(world: "MessengerWorld") -> None: available_portals.remove(warp_point) if shuffle_type < ShufflePortals.option_anywhere: - available_portals -= shop_points[out_to_parent[warp_point]] + available_portals = [portal for portal in available_portals + if portal not in shop_points[out_to_parent[warp_point]]] if not validate_portals(world): disconnect_portals(world) @@ -237,9 +238,9 @@ def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> Non def disconnect_portals(world: "MessengerWorld") -> None: for portal in OUTPUT_PORTALS: - entrance = world.multiworld.get_entrance(portal, world.player) - entrance.parent_region.exits.remove(entrance) - entrance.connected_region.exits.remove(entrance) + entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) + entrance.connected_region.entrances.remove(entrance) + entrance.connected_region = None def validate_portals(world: "MessengerWorld") -> bool: diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 82d62849f5ef..a7d25d1d0e7d 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -1,9 +1,9 @@ -from typing import Dict, TYPE_CHECKING +from typing import Dict, List, TYPE_CHECKING, Union from BaseClasses import CollectionState from worlds.generic.Rules import add_rule, allow_self_locking_items, CollectionRule from .constants import NOTES, PHOBEKINS -from .options import MessengerAccessibility +from .options import Logic, MessengerAccessibility if TYPE_CHECKING: from . import MessengerWorld @@ -248,8 +248,35 @@ def set_messenger_rules(self) -> None: self.world.options.accessibility.value = MessengerAccessibility.option_minimal -def parse_rule(rule_string: str, player: int) -> CollectionRule: - return lambda state: True +def parse_rule(rule_string: Union[str, List[str]], player: int, logic_level: int) -> CollectionRule: + if not rule_string or rule_string == "True": + return lambda state: True + + def parse_string(rule_string: str) -> CollectionRule: + if "hard" in rule_string: + if logic_level >= Logic.option_hard: + rule_string.replace(", hard", "") + items = rule_string.split(", ") + return lambda state: state.has_all(items, player) + return lambda state: True + # elif "challenging" in rule_string: + # if logic_level >= Logic.option_challenging: + # items = rule_string.split(", ") + # return lambda state: state.has_all(items, player) + # return lambda state: True + items = rule_string.split(", ") + return lambda state: state.has_all(items, player) + + if isinstance(rule_string, list): + combined_rule = None + for rule in rule_string: + if combined_rule is None: + combined_rule = parse_string(rule) + else: + combined_rule = combined_rule or parse_string(rule) + return combined_rule + else: + return parse_string(rule_string) def set_self_locking_items(world: "MessengerWorld", player: int) -> None: From 7acf628d11b7dbc7fadaa21287ed3bc7a331317a Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 24 Jan 2024 11:40:52 -0600 Subject: [PATCH 107/163] finish initial logic implementation --- worlds/messenger/__init__.py | 17 ++-- worlds/messenger/client_setup.py | 66 +++++++------ worlds/messenger/connections.py | 130 +++++++++++++------------ worlds/messenger/constants.py | 3 +- worlds/messenger/options.py | 4 +- worlds/messenger/portals.py | 49 +++++----- worlds/messenger/regions.py | 26 ++--- worlds/messenger/rules.py | 140 ++++++++++++++------------- worlds/messenger/subclasses.py | 6 +- worlds/messenger/test/__init__.py | 3 +- worlds/messenger/test/test_access.py | 20 +++- 11 files changed, 247 insertions(+), 217 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 2dab6121aff8..f8b118baa3a8 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -3,6 +3,7 @@ from BaseClasses import CollectionState, Item, ItemClassification, Tutorial from Options import Accessibility +from Utils import visualize_regions from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components @@ -12,7 +13,7 @@ from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded from .portals import SHUFFLEABLE_PORTAL_ENTRANCES, add_closed_portal_reqs, disconnect_portals, shuffle_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS -from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules, parse_rule +from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerItem, MessengerRegion @@ -121,18 +122,12 @@ def create_regions(self) -> None: for reg_name in sub_region]: region_name = region.name.replace(f"{region.parent} - ", "") connection_data = CONNECTIONS[region.parent][region_name] - for index, exit_region in enumerate(connection_data["exits"]): - region_exit = region.connect(self.multiworld.get_region(exit_region, self.player)) - region_exit.access_rule = parse_rule(connection_data["rules"][index], self.player, self.options.logic_level.value) + for exit_region in connection_data["exits"]: + region.connect(self.multiworld.get_region(exit_region, self.player)) # all regions need to be created before i can do these connections so we create and connect the complex first for region_name in [level for level in LEVELS if level in REGION_CONNECTIONS]: region = self.multiworld.get_region(region_name, self.player) region.add_exits(REGION_CONNECTIONS[region.name]) - for reg_exit in region.exits: - rules = REGION_CONNECTIONS[region.name][reg_exit.connected_region.name] - if isinstance(rules, dict): - for rule in rules.values(): - reg_exit.access_rule = parse_rule(rule, self.player) def create_items(self) -> None: # create items that are always in the item pool @@ -193,6 +188,7 @@ def create_items(self) -> None: self.multiworld.itempool += filler def set_rules(self) -> None: + MessengerRules(self).set_messenger_rules() # logic = self.options.logic_level # if logic == Logic.option_normal: # MessengerRules(self).set_messenger_rules() @@ -205,6 +201,7 @@ def set_rules(self) -> None: if self.options.shuffle_portals: disconnect_portals(self) shuffle_portals(self) + visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) def fill_slot_data(self) -> Dict[str, Any]: slot_data = { @@ -213,7 +210,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "max_price": self.total_shards, "required_seals": self.required_seals, "starting_portals": self.starting_portals, - "portal_mapping": self.portal_mapping if self.portal_mapping else [], + "portal_exits": self.portal_mapping if self.portal_mapping else [], **self.options.as_dict("music_box", "death_link", "logic_level"), } print(slot_data) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index f6e0b1d740d1..b009d60c96e3 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -17,14 +17,30 @@ def launch_game() -> None: """Check the game installation, then launch it""" if not (is_linux or is_windows): return - + def courier_installed() -> bool: """Check if Courier is installed""" return os.path.exists(os.path.join(folder, "miniinstaller-log.txt")) - + + def mod_installed() -> bool: + """Check if the mod is installed""" + return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) + + def request_data(url: str) -> Any: + """Fetches json response from given url""" + logging.info(f"requesting {url}") + response = requests.get(url) + if response.status_code == 200: # success + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") + else: + raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") + return data + def install_courier() -> None: """Installs latest version of Courier""" - # can't use latest since courier uses pre-release tags courier_url = "https://api.github.com/repos/Brokemia/Courier/releases" latest_download = request_data(courier_url)[0]["assets"][-1]["browser_download_url"] @@ -35,6 +51,7 @@ def install_courier() -> None: working_directory = os.getcwd() os.chdir(folder) + # linux handling if is_linux: mono_exe = which("mono") if not mono_exe: @@ -52,6 +69,7 @@ def install_courier() -> None: installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) else: installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe"), shell=False) + failure = installer.wait() if failure: messagebox("Failure", "Failed to install Courier", True) @@ -63,23 +81,19 @@ def install_courier() -> None: messagebox("Success!", "Courier successfully installed!") messagebox("Failure", "Failed to install Courier", True) raise RuntimeError("Failed to install Courier") - - def mod_installed() -> bool: - """Check if the mod is installed""" - return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) - - def request_data(url: str) -> Any: - """Fetches json response from given url""" - logging.info(f"requesting {url}") - response = requests.get(url) - if response.status_code == 200: # success - try: - data = response.json() - except requests.exceptions.JSONDecodeError: - raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") - else: - raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") - return data + + def install_mod() -> None: + """Installs latest version of the mod""" + # TODO: add /latest before actual PR since i want pre-releases for now + url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" + assets = request_data(url)["assets"] + release_url = assets[-1]["browser_download_url"] + + with urllib.request.urlopen(release_url) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + zf.extractall(folder) + + messagebox("Success!", "Latest mod successfully installed!") def available_mod_update() -> bool: """Check if there's an available update""" @@ -93,18 +107,6 @@ def available_mod_update() -> bool: return tuplize_version(latest_version) > tuplize_version(installed_version) - def install_mod() -> None: - """Installs latest version of the mod""" - url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" - assets = request_data(url)["assets"] - release_url = assets[0]["browser_download_url"] - - with urllib.request.urlopen(release_url) as download: - with ZipFile(io.BytesIO(download.read()), "r") as zf: - zf.extractall(folder) - - messagebox("Success!", "Latest mod successfully installed!") - from . import MessengerWorld folder = os.path.dirname(MessengerWorld.settings.game_path) if not courier_installed(): diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 76b7cbc6e53d..5eac50ba455a 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -6,14 +6,21 @@ "Right": { "exits": [ "Autumn Hills - Left", + "Ninja Village - Nest", ], "rules": ["True"], }, + "Nest": { + "exits": [ + "Ninja Village - Right", + ] + } }, "Autumn Hills": { "Left": { "exits": [ "Ninja Village - Right", + "Autumn Hills - Climbing Claws Shop", ], "rules": ["True"], }, @@ -27,6 +34,7 @@ "Bottom": { "exits": [ "Catacombs - Bottom Left", + "Autumn Hills - Double Swing Checkpoint", ], "rules": ["True"], }, @@ -47,7 +55,7 @@ "Hope Path Shop": { "exits": [ "Autumn Hills - Climbing Claws Shop", - "Autumn Hills - Hope Path Checkpoint", + "Autumn Hills - Hope Latch Checkpoint", "Autumn Hills - Lakeside Checkpoint", ], "rules": ["True", "True", "True"], @@ -67,7 +75,7 @@ ], "rules": ["True", "True"], }, - "Hope Path Checkpoint": { + "Hope Latch Checkpoint": { "exits": [ "Autumn Hills - Hope Path Shop", "Autumn Hills - Key of Hope Checkpoint", @@ -76,7 +84,7 @@ }, "Key of Hope Checkpoint": { "exits": [ - "Autumn Hills - Hope Path Checkpoint", + "Autumn Hills - Hope Latch Checkpoint", "Autumn Hills - Lakeside Checkpoint", ], "rules": ["True", "True", "True"], @@ -92,6 +100,7 @@ "exits": [ "Autumn Hills - Dimension Climb Shop", "Autumn Hills - Spike Ball Swing Checkpoint", + "Autumn Hills - Bottom", ], "rules": ["True", "True", "True"], }, @@ -138,21 +147,7 @@ "Forlorn Temple - Outside Shop", "Forlorn Temple - Sunny Day Checkpoint", ], - "rules": ["True", "True", "True"], - }, - "Sunny Day Checkpoint": { - "exits": [ - "Forlorn Temple - Outside Shop", - "Forlorn Temple - Rocket Maze Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Rocket Maze Checkpoint": { - "exits": [ - "Forlorn Temple - Sunny Day Checkpoint", - "Forlorn Temple - Climb Shop", - ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Climb Shop": { "exits": [ @@ -189,6 +184,20 @@ ], "rules": ["True", "True", "True"], }, + "Sunny Day Checkpoint": { + "exits": [ + "Forlorn Temple - Entrance Shop", + "Forlorn Temple - Rocket Maze Checkpoint", + ], + "rules": ["True", "True", "True"], + }, + "Rocket Maze Checkpoint": { + "exits": [ + "Forlorn Temple - Sunny Day Checkpoint", + "Forlorn Temple - Climb Shop", + ], + "rules": ["True", "True", "True"], + }, }, "Catacombs": { "Top Left": { @@ -323,28 +332,28 @@ "Bamboo Creek - Right", "Howling Grotto - Wingsuit Shop", ], - "rules": ["True", "True", "True"], + "rules": ["Wingsuit", "True"], }, "Top": { "exits": [ "Howling Grotto - Crushing Pits Shop", - "Quillshroom Marsh - Bottom Right", + "Quillshroom Marsh - Bottom Left", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Right": { "exits": [ "Howling Grotto - Emerald Golem Shop", "Quillshroom Marsh - Top Left", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Bottom": { "exits": [ "Howling Grotto - Lost Woods Checkpoint", "Sunken Shrine - Left", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Portal": { "exits": [ @@ -358,13 +367,14 @@ "Howling Grotto - Left", "Howling Grotto - Lost Woods Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["Wingsuit", "True"], }, "Crushing Pits Shop": { "exits": [ "Howling Grotto - Lost Woods Checkpoint", "Howling Grotto - Portal", "Howling Grotto - Breezy Crushers Checkpoint", + "Howling Grotto - Top", ], "rules": ["True", "True", "True"], }, @@ -482,21 +492,21 @@ "Searing Crags - Colossuses Shop", "Glacial Peak - Bottom", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Bottom": { "exits": [ "Searing Crags - Portal", "Quillshroom Marsh - Bottom Right", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Right": { "exits": [ "Searing Crags - Portal", "Underworld - Left", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Portal": { "exits": [ @@ -506,28 +516,28 @@ "Searing Crags - Colossuses Shop", "Tower HQ", ], - "rules": ["True", "True", "True", "True", "True"], + "rules": ["True", "Lightfoot Tabi", "Wingsuit", "Wingsuit", "True"], }, "Rope Dart Shop": { "exits": [ "Searing Crags - Left", "Searing Crags - Triple Ball Spinner Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["True", ["Wingsuit", "Rope Dart"]], }, "Triple Ball Spinner Shop": { "exits": [ "Searing Crags - Triple Ball Spinner Checkpoint", "Searing Crags - Searing Mega Shard Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Searing Mega Shard Shop": { "exits": [ "Searing Crags - Triple Ball Spinner Shop", "Searing Crags - Raining Rocks Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Before Final Climb Shop": { "exits": [ @@ -544,27 +554,27 @@ "Searing Crags - Portal", "Searing Crags - Top", ], - "rules": ["True", "True", "True", "True"], + "rules": ["True", ["Power Thistle, [Rope Dart, 'Wingsuit, Second Strike']"], "True", "True"], }, "Key of Strength Shop": { "exits": [ "Searing Crags - Searing Mega Shard Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True"], }, "Triple Ball Spinner Checkpoint": { "exits": [ "Searing Crags - Rope Dart Shop", "Searing Crags - Triple Ball Spinner Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Raining Rocks Checkpoint": { "exits": [ "Searing Crags - Searing Mega Shard Shop", "Searing Crags - Before Final Climb Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, }, "Glacial Peak": { @@ -589,14 +599,14 @@ "Cloud Ruins - Left", "Glacial Peak - Portal", ], - "rules": ["True", "True", "True"], + "rules": ["True", "Ruxxtin's Amulet", "True"], }, "Portal": { "exits": [ "Glacial Peak - Top", "Tower HQ", ], - "rules": ["True", "True", "True"], + "rules": [["Wingsuit", "Rope Dart"], "True"], }, "Ice Climbers' Shop": { "exits": [ @@ -644,11 +654,11 @@ "Tower of Time": { "Left": { "exits": [ - "Tower of Time - Entrance Shop", + "Tower of Time - Final Chance Shop", ], "rules": ["True", "True", "True"], }, - "Entrance Shop": { + "Final Chance Shop": { "exits": [ "Tower of Time - First Checkpoint", ], @@ -663,7 +673,7 @@ }, "First Checkpoint": { "exits": [ - "Tower of Time - Entrance Shop", + "Tower of Time - Final Chance Shop", "Tower of Time - Second Checkpoint", ], "rules": ["True", "True", "True"], @@ -708,11 +718,11 @@ "Left": { "exits": [ "Glacial Peak - Top", - "Cloud Ruins - Entrance Shop", + "Cloud Ruins - Cloud Entrance Shop", ], "rules": ["True", "True", "True"], }, - "Entrance Shop": { + "Cloud Entrance Shop": { "exits": [ "Cloud Ruins - Left", "Cloud Ruins - Spike Float Checkpoint", @@ -725,14 +735,14 @@ "Cloud Ruins - Ghost Pit Checkpoint", "Cloud Ruins - Crushers' Descent Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "Wingsuit, [Rope Dart, Meditation, Path of Resilience]", "True"], }, "Crushers' Descent Shop": { "exits": [ "Cloud Ruins - Pillar Glide Shop", "Cloud Ruins - Toothbrush Alley Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Seeing Spikes Shop": { "exits": [ @@ -746,33 +756,33 @@ "Cloud Ruins - Seeing Spikes Shop", "Cloud Ruins - Saw Pit Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Final Flight Shop": { "exits": [ "Cloud Ruins - Saw Pit Checkpoint", "Cloud Ruins - Manfred's Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Manfred's Shop": { "exits": [ "Cloud Ruins - Final Flight Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True"], }, "Spike Float Checkpoint": { "exits": [ - "Cloud Ruins - Entrance Shop", + "Cloud Ruins - Cloud Entrance Shop", "Cloud Ruins - Pillar Glide Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Ghost Pit Checkpoint": { "exits": [ - "Cloud Ruins - Spike Float Checkpoint", + "Cloud Ruins - Pillar Glide Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True"], }, "Toothbrush Alley Checkpoint": { "exits": [ @@ -792,12 +802,12 @@ "Underworld": { "Left": { "exits": [ - "Underworld - Entrance Shop", + "Underworld - Left Shop", "Searing Crags - Right", ], "rules": ["True", "True", "True"], }, - "Entrance Shop": { + "Left Shop": { "exits": [ "Underworld - Left", "Underworld - Hot Dip Checkpoint", @@ -832,7 +842,7 @@ }, "Hot Dip Checkpoint": { "exits": [ - "Underworld - Entrance Shop", + "Underworld - Left Shop", "Underworld - Fireball Wave Shop", "Underworld - Lava Run Checkpoint", ], @@ -942,26 +952,26 @@ "Portal": { "exits": [ "Sunken Shrine - Left", - "Sunken Shrine - Entrance Shop", + "Sunken Shrine - Above Portal Shop", "Sunken Shrine - Sun Path Shop", "Sunken Shrine - Moon Path Shop", "Tower HQ", ], - "rules": ["True", "True", "True", "True", "True"], + "rules": ["True", "True", "Lightfoot Tabi", "Lightfoot Tabi", "True"], }, - "Entrance Shop": { + "Above Portal Shop": { "exits": [ "Sunken Shrine - Portal", "Sunken Shrine - Lifeguard Shop", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Lifeguard Shop": { "exits": [ - "Sunken Shrine - Entrance Shop", + "Sunken Shrine - Above Portal Shop", "Sunken Shrine - Lightfoot Tabi Checkpoint", ], - "rules": ["True", "True", "True"], + "rules": ["True", "True"], }, "Sun Path Shop": { "exits": [ diff --git a/worlds/messenger/constants.py b/worlds/messenger/constants.py index 92379e3064c1..7a74856a04b3 100644 --- a/worlds/messenger/constants.py +++ b/worlds/messenger/constants.py @@ -127,7 +127,7 @@ "Glacial Peak Seal - Ice Climbers", "Glacial Peak Seal - Projectile Spike Pit", "Glacial Peak Seal - Glacial Air Swag", - "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", "Cloud Ruins Seal - Ghost Pit", @@ -144,6 +144,7 @@ "Sunken Shrine Seal - Waterfall Paradise", "Sunken Shrine Seal - Tabi Gauntlet", "Riviere Turquoise Seal - Bounces and Balls", + "Riviere Turquoise Seal - Launch of Faith", "Riviere Turquoise Seal - Flower Power", "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Water", diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index aae546e29c9a..febb7eff4ed8 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -50,7 +50,7 @@ class AvailablePortals(Range): display_name = "Number of Available Starting Portals" range_start = 3 range_end = 6 - default = 4 + default = 6 class ShufflePortals(TextChoice): @@ -70,7 +70,7 @@ class ShufflePortals(TextChoice): option_shops = 1 option_checkpoints = 2 option_anywhere = 3 - default = 2 + default = 0 class Goal(Choice): diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 5008d7376b7d..7b05713211a4 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -34,7 +34,7 @@ "Searing Crags", "Glacial Peak", "Tower of Time", - "CloudRuins", + "Cloud Ruins", "Underworld", "Riviere Turquoise", "Sunken Shrine", @@ -95,29 +95,30 @@ ], "Cloud Ruins": [ "Entrance", - "First Gap", - "Left Middle", - "Right Middle", - "Pre Acro", - "Pre Manfred", + "Pillar Glide", + "Crushers' Descent", + "Seeing Spikes", + "Final Flight", + "Manfred's", ], "Underworld": [ "Left", - "Spike Wall", - "Middle", - "Right", + "Fireball Wave", + "Long Climb", + # "Barm'athaziel", # not currently valid + "Key of Chaos", ], "Riviere Turquoise": [ - "Pre Fairy", - "Pre Flower Pit", - "Pre Restock", - "Pre Ascension", + "Waterfall", "Launch of Faith", - "Post Waterfall", + "Log Flume", + "Log Climb", + "Restock", + "Butterfly Matriarch", ], "Sunken Shrine": [ "Above Portal", - "Ultra Lifeguard", + "Lifeguard", "Sun Path", "Tabi Gauntlet", "Moon Path", @@ -127,7 +128,7 @@ CHECKPOINTS = { "Autumn Hills": [ - "Hope Path", + "Hope Latch", "Key of Hope", "Lakeside", "Double Swing", @@ -173,18 +174,18 @@ "Sixth", ], "Cloud Ruins": [ - "Time Warp", + "Spike Float", "Ghost Pit", - "Toothrush Alley", + "Toothbrush Alley", "Saw Pit", ], "Underworld": [ - "Sharp Drop", - "Final Stretch", + "Hot Dip", "Hot Tub", + "Lava Run", ], "Riviere Turquoise": [ - "Water Logged", + "Flower Flight", ], "Sunken Shrine": [ "Lightfoot Tabi", @@ -209,6 +210,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: for portal in OUTPUT_PORTALS: warp_point = world.random.choice(available_portals) parent = out_to_parent[warp_point] + # determine the name of the region of the warp point and save it in our exit_string = f"{parent.strip(' ')} - " if "Portal" in warp_point: exit_string += "Portal" @@ -226,14 +228,15 @@ def shuffle_portals(world: "MessengerWorld") -> None: available_portals = [portal for portal in available_portals if portal not in shop_points[out_to_parent[warp_point]]] + print(f"exits: {world.portal_mapping}") if not validate_portals(world): disconnect_portals(world) shuffle_portals(world) def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> None: - (world.multiworld.get_region("Tower HQ", world.player) - .connect(world.multiworld.get_region(out_region, world.player), portal)) + entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) + entrance.connect(world.multiworld.get_region(out_region, world.player)) def disconnect_portals(world: "MessengerWorld") -> None: diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 0a46326e312e..8b0fad09cc56 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -2,9 +2,10 @@ LOCATIONS: Dict[str, List[str]] = { - "Ninja Village": [ + "Ninja Village - Nest": [ "Ninja Village - Candle", - "Ninja Village - Astral Seed" + "Ninja Village - Astral Seed", + "Ninja Village Seal - Tree House", ], "Autumn Hills - Climbing Claws Shop": [ "Autumn Hills - Climbing Claws", @@ -114,7 +115,7 @@ "Glacial Peak Seal - Glacial Air Swag", ], "Tower of Time - First Checkpoint": [ - "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Time Waster", ], "Tower of Time - Third Checkpoint": [ "Tower of Time Seal - Lantern Climb", @@ -131,10 +132,13 @@ "Cloud Ruins - Saw Pit Checkpoint": [ "Cloud Ruins Seal - Saw Pit", ], - "Cloud Ruins - Final Flight Checkpoint": [ + "Cloud Ruins - Final Flight Shop": [ "Cloud Ruins - Acro", ], - "Underworld - Entrance Shop": [ + "Cloud Ruins - Manfred's Shop": [ + "Cloud Ruins Seal - Money Farm Room", + ], + "Underworld - Left Shop": [ "Underworld Seal - Sharp and Windy Climb", ], "Underworld - Fireball Wave Shop": [ @@ -371,16 +375,16 @@ "Quillshroom Marsh - Spikey Window Shop": ["Quillshroom Marsh Mega Shard"], "Searing Crags - Searing Mega Shard Shop": ["Searing Crags Mega Shard"], "Glacial Peak - Glacial Mega Shard Shop": ["Glacial Peak Mega Shard"], - "Cloud Ruins - Entrance Shop": ["Cloud Entrance Mega Shard"], + "Cloud Ruins - Cloud Entrance Shop": ["Cloud Entrance Mega Shard"], "Cloud Ruins - Spike Float Checkpoint": ["Time Warp Mega Shard"], "Cloud Ruins - Manfred's Shop": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], - "Underworld - Entrance Shop": ["Under Entrance Mega Shard"], + "Underworld - Left Shop": ["Under Entrance Mega Shard"], "Underworld - Hot Tub Checkpoint": ["Hot Tub Mega Shard", "Projectile Pit Mega Shard"], "Forlorn Temple - Sunny Day Checkpoint": ["Sunny Day Mega Shard"], "Forlorn Temple - Demon King Shop": ["Down Under Mega Shard"], - "Sunken Shrine - Waterfall Shop": ["Mega Shard of the Moon"], + "Sunken Shrine - Waterfall Paradise Checkpoint": ["Mega Shard of the Moon"], "Sunken Shrine - Portal": ["Beginner's Mega Shard"], - "Sunken Shrine - Entrance Shop": ["Mega Shard of the Stars"], + "Sunken Shrine - Above Portal Shop": ["Mega Shard of the Stars"], "Sunken Shrine - Sun Crest Checkpoint": ["Mega Shard of the Sun"], "Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"], "Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], @@ -388,7 +392,7 @@ } -REGION_CONNECTIONS: Dict[str, Union[Dict[str, str], str]] = { +REGION_CONNECTIONS: Dict[str, Dict[str, str]] = { "Menu": {"Tower HQ": "Start Game"}, "Tower HQ": { "Autumn Hills - Portal": "ToTHQ Autumn Hills Portal", @@ -406,7 +410,7 @@ "The Craftsman's Corner": "Money Sink", }, } -"""Vanilla layout mapping with all Tower HQ portals open. format is source[entrance_name][exit_region] or source[exit_region]""" +"""Vanilla layout mapping with all Tower HQ portals open. format is source[exit_region][entrance_name]""" # regions that don't have sub-regions and their exits diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index a7d25d1d0e7d..1586ff262b5f 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -12,6 +12,7 @@ class MessengerRules: player: int world: "MessengerWorld" + connection_rules: Dict[str, CollectionRule] region_rules: Dict[str, CollectionRule] location_rules: Dict[str, CollectionRule] maximum_price: int @@ -27,16 +28,70 @@ def __init__(self, world: "MessengerWorld") -> None: self.maximum_price = min(maximum_price, world.total_shards) self.required_seals = max(1, world.required_seals) + # dict of connection names and requirements to traverse the exit + self.connection_rules = { + # from ToTHQ + "Artificer's Portal": + lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player), + "Shrink Down": + lambda state: (state.has_all(NOTES, self.player) or self.has_enough_seals(state)) + and self.has_dart(state), + # the shop + "Money Sink": + lambda state: state.has("Money Wrench", self.player) and self.can_shop(state), + # Autumn Hills + "Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Autumn Hills - Hope Path Shop -> Autumn Hills - Hope Latch Checkpoint": + self.has_dart, + # Forlorn Temple + "Forlorn Temple - Outside Shop -> Forlorn Temple - Entrance Shop": + lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), + # Bamboo Creek + "Bamboo Creek - Top Left -> Forlorn Temple - Right": + lambda state: state.multiworld.get_region("Forlorn Temple - Right", self.player).can_reach(state), + # Howling Grotto + "Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop": + self.has_wingsuit, + "Howling Grotto - Left -> Bamboo Creek - Right": + self.has_wingsuit, + # Searing Crags + "Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint": + self.has_vertical, + "Searing Crags - Portal -> Searing Crags - Right": + self.has_tabi, + "Searing Crags - Portal -> Searing Crags - Before Final Climb Shop": + self.has_wingsuit, + "Searing Crags - Portal -> Searing Crags - Colossuses Shop": + self.has_wingsuit, + "Searing Crags - Colossuses Shop -> Searing Crags - Key of Strength Shop": + lambda state: state.has("Power Thistle", self.player) + and (self.has_dart(state) + or (self.has_wingsuit(state) + and self.can_destroy_projectiles(state))), + # Glacial Peak + "Glacial Peak - Portal -> Glacial Peak - Top": + self.has_vertical, + "Glacial Peak - Left -> Elemental Skylands": + lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state), + "Glacial Peak - Top -> Cloud Ruins - Left": + lambda state: self.has_vertical(state) and state.has("Ruxxtin's Amulet", self.player), + # Cloud Ruins + "Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Pillar Glide Shop": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Ghost Pit Checkpoint": + self.has_dart, + # Riviere Turquoise + "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": + lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), + # Sunken Shrine + "Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop": + self.has_tabi, + "Sunken Shrine - Portal -> Sunken Shrine - Moon Path Shop": + self.has_tabi, + } + self.region_rules = { - "Ninja Village": self.has_wingsuit, - "Autumn Hills": self.has_wingsuit, - "Catacombs": self.has_wingsuit, - "Bamboo Creek": self.has_wingsuit, - "Searing Crags Upper": self.has_vertical, - "Cloud Ruins": lambda state: self.has_vertical(state) and state.has("Ruxxtin's Amulet", self.player), - "Cloud Ruins Right": lambda state: self.has_wingsuit(state) and - (self.has_dart(state) or self.can_dboost(state)), - "Underworld": self.has_tabi, "Riviere Turquoise": lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), @@ -51,7 +106,6 @@ def __init__(self, world: "MessengerWorld") -> None: # ninja village "Ninja Village Seal - Tree House": self.has_dart, # autumn hills - "Autumn Hills - Key of Hope": self.has_dart, "Autumn Hills Seal - Spike Ball Darts": self.is_aerobatic, # bamboo creek "Bamboo Creek - Claustro": lambda state: self.has_dart(state) or self.can_dboost(state), @@ -60,18 +114,11 @@ def __init__(self, world: "MessengerWorld") -> None: "Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state), "Howling Grotto - Emerald Golem": self.has_wingsuit, # searing crags - "Searing Crags Seal - Triple Ball Spinner": self.has_vertical, "Searing Crags - Astral Tea Leaves": lambda state: state.can_reach("Ninja Village - Astral Seed", "Location", self.player), - "Searing Crags - Key of Strength": lambda state: state.has("Power Thistle", self.player) - and (self.has_dart(state) - or (self.has_wingsuit(state) - and self.can_destroy_projectiles(state))), # glacial peak "Glacial Peak Seal - Ice Climbers": self.has_dart, "Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles, - # cloud ruins - "Cloud Ruins Seal - Ghost Pit": self.has_dart, # tower of time "Tower of Time Seal - Time Waster": self.has_dart, "Tower of Time Seal - Lantern Climb": lambda state: self.has_wingsuit(state) and self.has_dart(state), @@ -81,13 +128,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Underworld Seal - Fireball Wave": self.is_aerobatic, "Underworld Seal - Rising Fanta": self.has_dart, # sunken shrine - "Sunken Shrine - Sun Crest": self.has_tabi, - "Sunken Shrine - Moon Crest": self.has_tabi, "Sunken Shrine - Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), - "Sunken Shrine Seal - Waterfall Paradise": self.has_tabi, - "Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi, - "Mega Shard of the Moon": self.has_tabi, - "Mega Shard of the Sun": self.has_tabi, # riviere turquoise "Riviere Turquoise Seal - Bounces and Balls": self.can_dboost, "Riviere Turquoise Seal - Launch of Faith": lambda state: self.can_dboost(state) or self.has_dart(state), @@ -99,11 +140,6 @@ def __init__(self, world: "MessengerWorld") -> None: "Elemental Skylands Seal - Fire": lambda state: self.has_dart(state) and self.can_destroy_projectiles(state), "Earth Mega Shard": self.has_dart, "Water Mega Shard": self.has_dart, - # corrupted future - "Corrupted Future - Key of Courage": lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, - self.player), - # tower hq - "Money Wrench": self.can_shop, } def has_wingsuit(self, state: CollectionState) -> bool: @@ -141,17 +177,16 @@ def can_shop(self, state: CollectionState) -> bool: def set_messenger_rules(self) -> None: multiworld = self.world.multiworld - for region in multiworld.get_regions(self.player): - if region.name in self.region_rules: - for entrance in region.entrances: - entrance.access_rule = self.region_rules[region.name] - for loc in region.locations: - if loc.name in self.location_rules: - loc.access_rule = self.location_rules[loc.name] + for entrance_name, rule in self.connection_rules.items(): + entrance = multiworld.get_entrance(entrance_name, self.player) + entrance.access_rule = rule + for loc in multiworld.get_locations(self.player): + if loc.name in self.location_rules: + loc.access_rule = self.location_rules[loc.name] multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) - if multiworld.accessibility[self.player]: # not locations accessibility - set_self_locking_items(self.world, self.player) + # if multiworld.accessibility[self.player]: # not locations accessibility + # set_self_locking_items(self.world, self.player) class MessengerHardRules(MessengerRules): @@ -248,37 +283,6 @@ def set_messenger_rules(self) -> None: self.world.options.accessibility.value = MessengerAccessibility.option_minimal -def parse_rule(rule_string: Union[str, List[str]], player: int, logic_level: int) -> CollectionRule: - if not rule_string or rule_string == "True": - return lambda state: True - - def parse_string(rule_string: str) -> CollectionRule: - if "hard" in rule_string: - if logic_level >= Logic.option_hard: - rule_string.replace(", hard", "") - items = rule_string.split(", ") - return lambda state: state.has_all(items, player) - return lambda state: True - # elif "challenging" in rule_string: - # if logic_level >= Logic.option_challenging: - # items = rule_string.split(", ") - # return lambda state: state.has_all(items, player) - # return lambda state: True - items = rule_string.split(", ") - return lambda state: state.has_all(items, player) - - if isinstance(rule_string, list): - combined_rule = None - for rule in rule_string: - if combined_rule is None: - combined_rule = parse_string(rule) - else: - combined_rule = combined_rule or parse_string(rule) - return combined_rule - else: - return parse_string(rule_string) - - def set_self_locking_items(world: "MessengerWorld", player: int) -> None: multiworld = world.multiworld diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 23449c06b8a0..eeed986cd25f 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -2,10 +2,8 @@ from typing import Optional, TYPE_CHECKING from BaseClasses import CollectionState, Item, ItemClassification, Location, Region -from .connections import CONNECTIONS from .constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -from .regions import MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS -from .rules import parse_rule +from .regions import MEGA_SHARDS, LOCATIONS from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS if TYPE_CHECKING: @@ -22,7 +20,7 @@ def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = N if name in LOCATIONS: locations = [loc for loc in LOCATIONS[name]] # portal event locations since portals can be opened from their exit regions - if "Portal" in name: + if name.endswith("Portal"): locations.append(name.replace(" -", "")) if name == "The Shop": diff --git a/worlds/messenger/test/__init__.py b/worlds/messenger/test/__init__.py index 10e126c062c4..2ee939e34370 100644 --- a/worlds/messenger/test/__init__.py +++ b/worlds/messenger/test/__init__.py @@ -9,4 +9,5 @@ class MessengerTestBase(WorldTestBase): def setUp(self) -> None: super().setUp() - self.world = self.multiworld.worlds[self.player] + if self.constructed: + self.world = self.multiworld.worlds[self.player] diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py index 6f7be73f77af..45b38df109f9 100644 --- a/worlds/messenger/test/test_access.py +++ b/worlds/messenger/test/test_access.py @@ -22,11 +22,21 @@ def test_tabi(self) -> None: def test_dart(self) -> None: """locations that hard require the Rope Dart""" locations = [ - "Ninja Village Seal - Tree House", "Autumn Hills - Key of Hope", "Howling Grotto Seal - Crushing Pits", - "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb", - "Tower of Time Seal - Arcane Orbs", "Cloud Ruins Seal - Ghost Pit", "Underworld Seal - Rising Fanta", - "Elemental Skylands - Key of Symbiosis", "Elemental Skylands Seal - Water", - "Elemental Skylands Seal - Fire", "Earth Mega Shard", "Water Mega Shard", "Rescue Phantom", + "Ninja Village Seal - Tree House", + "Autumn Hills - Key of Hope", + "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", + "Tower of Time Seal - Time Waster", + "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + "Cloud Ruins Seal - Ghost Pit", + "Underworld Seal - Rising Fanta", + "Elemental Skylands - Key of Symbiosis", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire", + "Earth Mega Shard", + "Water Mega Shard", + "Rescue Phantom", ] items = [["Rope Dart"]] self.assertAccessDependency(locations, items) From 84d6333130205ace5085d713e4fea7dae820f916 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 25 Jan 2024 03:08:24 -0600 Subject: [PATCH 108/163] remove/comment out debug stuff --- worlds/messenger/__init__.py | 3 +-- worlds/messenger/options.py | 2 +- worlds/messenger/portals.py | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index f8b118baa3a8..f9c253442b4c 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -201,7 +201,7 @@ def set_rules(self) -> None: if self.options.shuffle_portals: disconnect_portals(self) shuffle_portals(self) - visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) + # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) def fill_slot_data(self) -> Dict[str, Any]: slot_data = { @@ -213,7 +213,6 @@ def fill_slot_data(self) -> Dict[str, Any]: "portal_exits": self.portal_mapping if self.portal_mapping else [], **self.options.as_dict("music_box", "death_link", "logic_level"), } - print(slot_data) return slot_data def get_filler_item_name(self) -> str: diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index febb7eff4ed8..cad108569e7c 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -70,7 +70,7 @@ class ShufflePortals(TextChoice): option_shops = 1 option_checkpoints = 2 option_anywhere = 3 - default = 0 + default = 2 class Goal(Choice): diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 7b05713211a4..7af3e49ff858 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -227,8 +227,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: if shuffle_type < ShufflePortals.option_anywhere: available_portals = [portal for portal in available_portals if portal not in shop_points[out_to_parent[warp_point]]] - - print(f"exits: {world.portal_mapping}") + if not validate_portals(world): disconnect_portals(world) shuffle_portals(world) From 56889f5fda846ed4121a49fa3f62427cd5fc4343 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 25 Jan 2024 03:18:57 -0600 Subject: [PATCH 109/163] does not actually support plando yet --- worlds/messenger/options.py | 1 - 1 file changed, 1 deletion(-) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index cad108569e7c..dcdaedd4c1f7 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -57,7 +57,6 @@ class ShufflePortals(TextChoice): """ Whether the portals lead to random places. Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant. - Supports plando. None: Portals will take you where they're supposed to. Shops: Portals can lead to any area except Music Box and Elemental Skylands, with each portal output guaranteed to not overlap with another portal's. Will only put you at a portal or a shop. From bbaa19f9bed0b1e8f97e50504681cbaf220251a4 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 25 Jan 2024 07:56:13 -0600 Subject: [PATCH 110/163] typos and fix a crash when 3 available portals was selected --- worlds/messenger/__init__.py | 1 + worlds/messenger/options.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index f9c253442b4c..219cd274d8c8 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -106,6 +106,7 @@ def generate_early(self) -> None: self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) + self.starting_portals = [] if self.options.available_portals > AvailablePortals.range_start: # there's 3 specific portals that the game forces open self.starting_portals = self.random.choices(SHUFFLEABLE_PORTAL_ENTRANCES, diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index dcdaedd4c1f7..11eea49d34fc 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -57,11 +57,11 @@ class ShufflePortals(TextChoice): """ Whether the portals lead to random places. Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant. - + None: Portals will take you where they're supposed to. Shops: Portals can lead to any area except Music Box and Elemental Skylands, with each portal output guaranteed to not overlap with another portal's. Will only put you at a portal or a shop. Checkpoints: Like Shops except checkpoints without shops are also valid drop points. - Anywhere: Like Shuffle except it's possible for multiple portals to output to the same map. + Anywhere: Like Checkpoints except it's possible for multiple portals to output to the same map. """ display_name = "Shuffle Portal Outputs" option_none = 0 @@ -69,7 +69,7 @@ class ShufflePortals(TextChoice): option_shops = 1 option_checkpoints = 2 option_anywhere = 3 - default = 2 + default = 0 class Goal(Choice): From 90163f6bad54ddf7f8d4b431133d748eb7514ba7 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 26 Jan 2024 08:15:46 -0600 Subject: [PATCH 111/163] finish initial logic for all connections and remove/rename as necessary --- worlds/messenger/connections.py | 29 ++---- worlds/messenger/constants.py | 1 + worlds/messenger/portals.py | 4 +- worlds/messenger/regions.py | 4 +- worlds/messenger/rules.py | 161 +++++++++++++++++++++++++++----- 5 files changed, 150 insertions(+), 49 deletions(-) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 5eac50ba455a..d55ea00cc104 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -166,27 +166,25 @@ "Descent Shop": { "exits": [ "Forlorn Temple - Rocket Sunset Shop", - "Forlorn Temple - Final Fall Shop", + "Forlorn Temple - Saw Gauntlet Shop", ], "rules": ["True", "True", "True"], }, - "Final Fall Shop": { + "Saw Gauntlet Shop": { "exits": [ - "Forlorn Temple - Descent Shop", "Forlorn Temple - Demon King Shop", ], "rules": ["True", "True", "True"], }, "Demon King Shop": { "exits": [ - "Forlorn Temple - Final Fall Shop", + "Forlorn Temple - Saw Gauntlet Shop", "Forlorn Temple - Right", ], "rules": ["True", "True", "True"], }, "Sunny Day Checkpoint": { "exits": [ - "Forlorn Temple - Entrance Shop", "Forlorn Temple - Rocket Maze Checkpoint", ], "rules": ["True", "True", "True"], @@ -277,7 +275,6 @@ }, "Top Left": { "exits": [ - "Forlorn Temple - Right", "Bamboo Creek - Abandoned Shop", ], "rules": ["True", "True", "True"], @@ -525,7 +522,7 @@ ], "rules": ["True", ["Wingsuit", "Rope Dart"]], }, - "Triple Ball Spinner Shop": { + "Falling Rocks Shop": { "exits": [ "Searing Crags - Triple Ball Spinner Checkpoint", "Searing Crags - Searing Mega Shard Shop", @@ -534,8 +531,8 @@ }, "Searing Mega Shard Shop": { "exits": [ - "Searing Crags - Triple Ball Spinner Shop", - "Searing Crags - Raining Rocks Checkpoint", + "Searing Crags - Falling Rocks Shop", + "Searing Crags - Before Final Climb Shop", ], "rules": ["True", "True"], }, @@ -565,7 +562,7 @@ "Triple Ball Spinner Checkpoint": { "exits": [ "Searing Crags - Rope Dart Shop", - "Searing Crags - Triple Ball Spinner Shop", + "Searing Crags - Falling Rocks Shop", ], "rules": ["True", "True"], }, @@ -666,49 +663,42 @@ }, "Arcane Golem Shop": { "exits": [ - "Tower HQ", "Tower of Time - Sixth Checkpoint", ], "rules": ["True", "True", "True"], }, "First Checkpoint": { "exits": [ - "Tower of Time - Final Chance Shop", "Tower of Time - Second Checkpoint", ], "rules": ["True", "True", "True"], }, "Second Checkpoint": { "exits": [ - "Tower of Time - First Checkpoint", "Tower of Time - Third Checkpoint", ], "rules": ["True", "True", "True"], }, "Third Checkpoint": { "exits": [ - "Tower of Time - Second Checkpoint", "Tower of Time - Fourth Checkpoint", ], "rules": ["True", "True", "True"], }, "Fourth Checkpoint": { "exits": [ - "Tower of Time - Third Checkpoint", "Tower of Time - Fifth Checkpoint", ], "rules": ["True", "True", "True"], }, "Fifth Checkpoint": { "exits": [ - "Tower of Time - Fourth Checkpoint", "Tower of Time - Sixth Checkpoint", ], "rules": ["True", "True", "True"], }, "Sixth Checkpoint": { "exits": [ - "Tower of Time - Fifth Checkpoint", "Tower of Time - Arcane Golem Shop", ], "rules": ["True", "True", "True"], @@ -836,7 +826,6 @@ }, "Key of Chaos Shop": { "exits": [ - "Underworld - Lava Run Checkpoint", ], "rules": ["True", "True", "True"], }, @@ -908,28 +897,24 @@ }, "Log Flume Shop": { "exits": [ - "Riviere Turquoise - Launch of Faith Shop", "Riviere Turquoise - Log Climb Shop", ], "rules": ["True", "True", "True"], }, "Log Climb Shop": { "exits": [ - "Riviere Turquoise - Log Flume Shop", "Riviere Turquoise - Restock Shop", ], "rules": ["True", "True", "True"], }, "Restock Shop": { "exits": [ - "Riviere Turquoise - Log Climb Shop", "Riviere Turquoise - Butterfly Matriarch Shop", ], "rules": ["True", "True", "True"], }, "Butterfly Matriarch Shop": { "exits": [ - "Riviere Turquoise - Restock Shop", ], "rules": ["True", "True", "True"], }, diff --git a/worlds/messenger/constants.py b/worlds/messenger/constants.py index 7a74856a04b3..33d3a83c391e 100644 --- a/worlds/messenger/constants.py +++ b/worlds/messenger/constants.py @@ -24,6 +24,7 @@ # "Astral Seed", # "Astral Tea Leaves", "Money Wrench", + "Candle", ] PHOBEKINS = [ diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 7af3e49ff858..9a3f841a569b 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -54,7 +54,7 @@ "Climb", "Rocket Sunset", "Descent", - "Final Fall", + "Saw Gauntlet", "Demon King", ], "Catacombs": [ @@ -78,7 +78,7 @@ ], "Searing Crags": [ "Rope Dart", - "Triple Ball Spinner", + "Falling Rocks", "Searing Mega Shard", "Before Final Climb", "Colossuses", diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 8b0fad09cc56..ff4b2283bba3 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -102,7 +102,7 @@ "Searing Crags - Key of Strength Shop": [ "Searing Crags - Key of Strength", ], - "Searing Crags - Right": [ + "Searing Crags - Portal": [ "Searing Crags - Pyro", ], "Glacial Peak - Ice Climbers' Shop": [ @@ -117,7 +117,7 @@ "Tower of Time - First Checkpoint": [ "Tower of Time Seal - Time Waster", ], - "Tower of Time - Third Checkpoint": [ + "Tower of Time - Fourth Checkpoint": [ "Tower of Time Seal - Lantern Climb", ], "Tower of Time - Fifth Checkpoint": [ diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 1586ff262b5f..dc7fc9104fe0 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -42,19 +42,46 @@ def __init__(self, world: "MessengerWorld") -> None: # Autumn Hills "Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop": lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Hope Path Shop": + self.has_dart, "Autumn Hills - Hope Path Shop -> Autumn Hills - Hope Latch Checkpoint": self.has_dart, + "Autumn Hills - Hope Path Shop -> Autumn Hills - Climbing Claws Shop": + lambda state: self.has_dart(state) and self.can_dboost(state), + "Autumn Hills - Hope Path Shop -> Autumn Hills - Lakeside Checkpoint": + lambda state: self.has_dart(state) and self.can_dboost(state), + "Autumn Hills - Hope Latch Checkpoint -> Autumn Hills - Hope Path Shop": + self.can_dboost, + "Autumn Hills - Hope Latch Checkpoint -> Autumn Hills - Key of Hope Checkpoint": + lambda state: self.has_dart(state) and self.has_wingsuit(state), # Forlorn Temple "Forlorn Temple - Outside Shop -> Forlorn Temple - Entrance Shop": - lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), - # Bamboo Creek - "Bamboo Creek - Top Left -> Forlorn Temple - Right": - lambda state: state.multiworld.get_region("Forlorn Temple - Right", self.player).can_reach(state), + lambda state: state.has_all(PHOBEKINS, self.player), + "Forlorn Temple - Entrance Shop -> Forlorn Temple - Outside Shop": + lambda state: state.has_all(PHOBEKINS, self.player), + "Forlorn Temple - Entrance Shop -> Forlorn Temple - Sunny Day Checkpoint": + lambda state: self.has_vertical(state) and self.can_dboost(state), + "Forlorn Temple - Sunny Day Checkpoint -> Forlorn Temple - Rocket Maze Checkpoint": + self.has_vertical, + "Forlorn Temple - Rocket Sunset Shop -> Forlorn Temple - Descent Shop": + lambda state: self.has_dart(state) and (self.can_dboost(state) or self.has_wingsuit(state)), + "Forlorn Temple - Saw Gauntlet Shop -> Forlorn Temple - Demon King Shop": + self.has_vertical, + "Forlorn Temple - Demon King Shop -> Forlorn Temple - Saw Gauntlet Shop": + self.has_vertical, # Howling Grotto "Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop": self.has_wingsuit, "Howling Grotto - Left -> Bamboo Creek - Right": self.has_wingsuit, + "Howling Grotto - Wingsuit Shop -> Howling Grotto - Lost Woods Checkpoint": + self.has_wingsuit, + "Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal": + lambda state: self.has_wingsuit(state) or self.can_dboost(state), + "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop": + self.has_wingsuit, + "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Crushing Pits Shop": + lambda state: self.has_wingsuit(state) or self.can_dboost(state) or self.can_destroy_projectiles(state), # Searing Crags "Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint": self.has_vertical, @@ -64,71 +91,156 @@ def __init__(self, world: "MessengerWorld") -> None: self.has_wingsuit, "Searing Crags - Portal -> Searing Crags - Colossuses Shop": self.has_wingsuit, + "Searing Crags - Bottom -> Searing Crags - Portal": + self.has_wingsuit, + "Searing Crags - Right -> Searing Crags - Portal": + lambda state: self.has_tabi(state) and self.has_wingsuit(state), "Searing Crags - Colossuses Shop -> Searing Crags - Key of Strength Shop": lambda state: state.has("Power Thistle", self.player) and (self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state))), + "Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop": + self.has_dart, # or strike for hard + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Before Final Climb Shop": + lambda state: self.has_dart(state) or self.can_destroy_projectiles(state), + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop": + self.has_dart, + "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": + self.has_dart, # doable itemless for hard, but only 16-bit # Glacial Peak "Glacial Peak - Portal -> Glacial Peak - Top": self.has_vertical, - "Glacial Peak - Left -> Elemental Skylands": - lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state), + "Glacial Peak - Left -> Elemental Skylands": # if we can ever shuffle elemental skylands stuff around wingsuit isn't needed here. + lambda state: state.has("Magic Firefly", self.player) + and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player).can_reach(state) + and self.has_wingsuit(state), "Glacial Peak - Top -> Cloud Ruins - Left": lambda state: self.has_vertical(state) and state.has("Ruxxtin's Amulet", self.player), + "Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left": + lambda state: self.has_dart(state) or (self.can_dboost(state) and self.has_wingsuit(state)), + # Tower of Time + "Tower of Time - Left -> Tower of Time - Final Chance Shop": + self.has_dart, + "Tower of Time - Second Checkpoint -> Tower of Time - Third Checkpoint": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Tower of Time - Third Checkpoint -> Tower of Time - Fourth Checkpoint": + lambda state: self.has_wingsuit(state) or self.can_dboost(state), + "Tower of Time - Fourth Checkpoint -> Tower of TIme - Fifth Checkpiont": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Tower of Time - Fifth Checkpoint -> Tower of Time - Sixth Checkpoint": + self.has_wingsuit, # Cloud Ruins + "Cloud Ruins - Cloud Entrance Shop -> Cloud Ruins - Spike Float Checkpoint": + self.has_wingsuit, + "Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Cloud Entrance Shop": + lambda state: self.has_vertical(state) or self.can_dboost(state), "Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Pillar Glide Shop": - lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Spike Float Checkpoint": + lambda state: self.has_vertical(state) and self.can_double_dboost(state), "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Ghost Pit Checkpoint": - self.has_dart, + lambda state: self.has_dart(state) and self.has_wingsuit(state), + "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Crushers' Descent Shop": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Cloud Ruins - Toothbrush Alley Checkpoint -> Cloud Ruins - Seeing Spikes Shop": + self.has_vertical, + "Cloud Ruins - Seeing Spikes Shop -> Cloud Ruins - Sliding Spikes Shop": + self.has_wingsuit, + "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Seeing Spikes Shop": + self.has_wingsuit, + "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint": + self.has_vertical, # nothing needed for hard + "Cloud Ruins - Final Flight Shop -> Cloud Ruins - Manfred's Shop": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Cloud Ruins - Manfred's Shop -> Cloud Ruins - Final Flight Shop": + lambda state: self.has_wingsuit(state) and self.can_dboost(state), + # Underworld + "Underworld - Left -> Underworld - Left Shop": + self.has_tabi, + "Underworld - Left Shop -> Underworld - Left": + self.has_tabi, + "Underworld - Hot Dip Checkpoint -> Underworld - Lava Run Checkpoint": + self.has_tabi, + "Underworld - Fireball Wave Shop -> Underworld - Long Climb Shop": + lambda state: self.can_destroy_projectiles(state) or self.has_tabi(state) or self.has_vertical(state), + "Underworld - Long Climb Shop -> Underworld - Hot Tub Checkpoint": + lambda state: self.has_tabi(state) + and (self.can_destroy_projectiles(state) + or self.has_wingsuit(state)) + or (self.has_wingsuit(state) + and (self.has_dart(state) + or self.can_dboost(state) + or self.can_destroy_projectiles(state))), + "Underworld - Hot Tub Checkpoint -> Underworld - Long Climb Shop": + lambda state: self.has_tabi(state) + or self.can_destroy_projectiles(state) + or (self.has_dart(state) and self.has_wingsuit(state)), + # Dark Cave + "Dark Cave - Right -> Dark Cave - Left": + lambda state: state.has("Candle", self.player), # Riviere Turquoise "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), + "Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint": + lambda state: self.has_dart(state) and self.can_dboost(state), # doable with only d-boosting but it's pretty hard so only for hard logic + "Riviere Turquoise - Flower Flight Checkpoint -> Waterfall Shop": + lambda state: False, # doable with d-boosting but it's pretty hard so only for hard logic # Sunken Shrine "Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop": self.has_tabi, "Sunken Shrine - Portal -> Sunken Shrine - Moon Path Shop": self.has_tabi, + "Sunken Shrine - Moon Path Shop -> Sunken Shrine - Waterfall Paradise Checkpoint": + self.has_tabi, + "Sunken Shrine - Waterfall Paradise Checkpoint -> Sunken Shrine - Moon Path Shop": + self.has_tabi, + "Sunken Shrine - Tabi Gauntlet Shop -> Sunken Shrine - Sun Path Shop": + lambda state: self.can_dboost(state) or self.has_dart(state), } - self.region_rules = { - "Riviere Turquoise": lambda state: self.has_dart(state) or - (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), - "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), - "Glacial Peak": self.has_vertical, - "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state), - "Music Box": lambda state: (state.has_all(NOTES, self.player) - or self.has_enough_seals(state)) and self.has_dart(state), - "The Craftsman's Corner": lambda state: state.has("Money Wrench", self.player) and self.can_shop(state), - } - self.location_rules = { # ninja village "Ninja Village Seal - Tree House": self.has_dart, # autumn hills "Autumn Hills Seal - Spike Ball Darts": self.is_aerobatic, + "Autumn Hills Seal - Trip Saws": self.has_wingsuit, + # forlorn temple + "Forlorn Temple Seal - Rocket Maze": self.has_vertical, # bamboo creek - "Bamboo Creek - Claustro": lambda state: self.has_dart(state) or self.can_dboost(state), + "Bamboo Creek - Claustro": lambda state: self.has_wingsuit(state) and + (self.has_dart(state) or self.can_dboost(state)), + "Above Entrance Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state), + "Bamboo Creek Seal - Spike Ball Pits": self.has_wingsuit, # howling grotto "Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit, "Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state), "Howling Grotto - Emerald Golem": self.has_wingsuit, # searing crags "Searing Crags - Astral Tea Leaves": - lambda state: state.can_reach("Ninja Village - Astral Seed", "Location", self.player), + lambda state: state.multiworld.get_location("Ninja Village - Astral Seed", self.player).can_reach(state), + "Searing Crags Seal - Triple Ball Spinner": self.can_dboost, + "Searing Crags - Pyro": + self.has_tabi, # glacial peak "Glacial Peak Seal - Ice Climbers": self.has_dart, "Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles, + "Time Warp Mega Shard": lambda state: self.has_vertical(state) or self.can_dboost(state), # tower of time "Tower of Time Seal - Time Waster": self.has_dart, - "Tower of Time Seal - Lantern Climb": lambda state: self.has_wingsuit(state) and self.has_dart(state), - "Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state), + # cloud ruins + "Cloud Ruins Seal - Toothbrush Alley": self.has_dart, # nothing needed for hard + "Cloud Ruins Seal - Saw Pit": self.has_vertical, # nothing for hard # underworld "Underworld Seal - Sharp and Windy Climb": self.has_wingsuit, "Underworld Seal - Fireball Wave": self.is_aerobatic, "Underworld Seal - Rising Fanta": self.has_dart, + "Hot Tub Mega Shard": lambda state: self.has_tabi(state) or self.has_dart(state), # sunken shrine "Sunken Shrine - Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), + "Sunken Shrine Seal - Waterfall Paradise": self.has_tabi, + "Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi, + "Mega Shard of the Sun": self.has_tabi, # riviere turquoise "Riviere Turquoise Seal - Bounces and Balls": self.can_dboost, "Riviere Turquoise Seal - Launch of Faith": lambda state: self.can_dboost(state) or self.has_dart(state), @@ -163,6 +275,9 @@ def can_destroy_projectiles(self, state: CollectionState) -> bool: def can_dboost(self, state: CollectionState) -> bool: return state.has_any({"Path of Resilience", "Meditation"}, self.player) and \ state.has("Second Wind", self.player) + + def can_double_dboost(self, state: CollectionState) -> bool: + return state.has_all({"Path of Resilience", "Meditation", "Second Wind"}, self.player) def is_aerobatic(self, state: CollectionState) -> bool: return self.has_wingsuit(state) and state.has("Aerobatics Warrior", self.player) From 2ac7d5f5c028cdb227128fd478c7f5916a2122d4 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 26 Jan 2024 08:54:01 -0600 Subject: [PATCH 112/163] fix typos and add some more leniency --- worlds/messenger/connections.py | 3 ++- worlds/messenger/regions.py | 5 ++--- worlds/messenger/rules.py | 8 ++++--- worlds/messenger/test/test_access.py | 32 ++++++++++++++++++++++++++++ worlds/messenger/test/test_shop.py | 19 ----------------- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index d55ea00cc104..265fd9e7c733 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -49,6 +49,7 @@ "exits": [ "Autumn Hills - Left", "Autumn Hills - Hope Path Shop", + "Autumn Hills - Lakeside Checkpoint", ], "rules": ["True", "True"], }, @@ -91,7 +92,7 @@ }, "Lakeside Checkpoint": { "exits": [ - "Autumn Hills - Hope Path Shop", + "Autumn Hills - Climbing Claws Shop", "Autumn Hills - Dimension Climb Shop", ], "rules": ["True", "True", "True"], diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index ff4b2283bba3..3b0a7c7fe522 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -280,7 +280,7 @@ "Right", "Portal", "Rope Dart Shop", - "Triple Ball Spinner Shop", + "Falling Rocks Shop", "Searing Mega Shard Shop", "Before Final Climb Shop", "Colossuses Shop", @@ -375,8 +375,7 @@ "Quillshroom Marsh - Spikey Window Shop": ["Quillshroom Marsh Mega Shard"], "Searing Crags - Searing Mega Shard Shop": ["Searing Crags Mega Shard"], "Glacial Peak - Glacial Mega Shard Shop": ["Glacial Peak Mega Shard"], - "Cloud Ruins - Cloud Entrance Shop": ["Cloud Entrance Mega Shard"], - "Cloud Ruins - Spike Float Checkpoint": ["Time Warp Mega Shard"], + "Cloud Ruins - Cloud Entrance Shop": ["Cloud Entrance Mega Shard", "Time Warp Mega Shard"], "Cloud Ruins - Manfred's Shop": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], "Underworld - Left Shop": ["Under Entrance Mega Shard"], "Underworld - Hot Tub Checkpoint": ["Hot Tub Mega Shard", "Projectile Pit Mega Shard"], diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index dc7fc9104fe0..71c56b989945 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -126,7 +126,7 @@ def __init__(self, world: "MessengerWorld") -> None: lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), "Tower of Time - Third Checkpoint -> Tower of Time - Fourth Checkpoint": lambda state: self.has_wingsuit(state) or self.can_dboost(state), - "Tower of Time - Fourth Checkpoint -> Tower of TIme - Fifth Checkpiont": + "Tower of Time - Fourth Checkpoint -> Tower of Time - Fifth Checkpoint": lambda state: self.has_wingsuit(state) and self.has_dart(state), "Tower of Time - Fifth Checkpoint -> Tower of Time - Sixth Checkpoint": self.has_wingsuit, @@ -184,7 +184,7 @@ def __init__(self, world: "MessengerWorld") -> None: lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), "Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint": lambda state: self.has_dart(state) and self.can_dboost(state), # doable with only d-boosting but it's pretty hard so only for hard logic - "Riviere Turquoise - Flower Flight Checkpoint -> Waterfall Shop": + "Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop": lambda state: False, # doable with d-boosting but it's pretty hard so only for hard logic # Sunken Shrine "Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop": @@ -202,6 +202,8 @@ def __init__(self, world: "MessengerWorld") -> None: self.location_rules = { # ninja village "Ninja Village Seal - Tree House": self.has_dart, + "Ninja Village - Candle": + lambda state: state.multiworld.get_location("Searing Crags - Astral Tea Leaves", self.player).can_reach(state), # autumn hills "Autumn Hills Seal - Spike Ball Darts": self.is_aerobatic, "Autumn Hills Seal - Trip Saws": self.has_wingsuit, @@ -225,10 +227,10 @@ def __init__(self, world: "MessengerWorld") -> None: # glacial peak "Glacial Peak Seal - Ice Climbers": self.has_dart, "Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles, - "Time Warp Mega Shard": lambda state: self.has_vertical(state) or self.can_dboost(state), # tower of time "Tower of Time Seal - Time Waster": self.has_dart, # cloud ruins + "Time Warp Mega Shard": lambda state: self.has_vertical(state) or self.can_dboost(state), "Cloud Ruins Seal - Toothbrush Alley": self.has_dart, # nothing needed for hard "Cloud Ruins Seal - Saw Pit": self.has_vertical, # nothing for hard # underworld diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py index 45b38df109f9..d91933e78c10 100644 --- a/worlds/messenger/test/test_access.py +++ b/worlds/messenger/test/test_access.py @@ -24,12 +24,18 @@ def test_dart(self) -> None: locations = [ "Ninja Village Seal - Tree House", "Autumn Hills - Key of Hope", + "Forlorn Temple - Demon King", + "Down Under Mega Shard", "Howling Grotto Seal - Crushing Pits", "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Money Farm Room", + "Cloud Ruins Seal - Toothbrush Alley", + "Money Farm Room Mega Shard 1", + "Money Farm Room Mega Shard 2", "Underworld Seal - Rising Fanta", "Elemental Skylands - Key of Symbiosis", "Elemental Skylands Seal - Water", @@ -146,6 +152,32 @@ def test_crown(self) -> None: items = [["Demon King Crown"]] self.assertAccessDependency(locations, items) + def test_dboost(self) -> None: + """ + short for damage boosting, d-boosting is a technique in video games where the player intentionally or + unintentionally takes damage and uses the several following frames of invincibility to defeat or get past an + enemy or obstacle, most commonly used in platformers such as the Super Mario games + """ + locations = [ + "Riviere Turquoise Seal - Bounces and Balls", "Searing Crags Seal - Triple Ball Spinner", + "Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + "Sunny Day Mega Shard", "Down Under Mega Shard", + ] + items = [["Path of Resilience", "Meditation", "Second Wind"]] + self.assertAccessDependency(locations, items) + + def test_currents(self) -> None: + """there's one of these but oh man look at it go""" + self.assertAccessDependency(["Elemental Skylands Seal - Water"], [["Currents Master"]]) + + def test_strike(self) -> None: + """strike is pretty cool but it doesn't block much""" + locations = [ + "Glacial Peak Seal - Projectile Spike Pit", "Elemental Skylands Seal - Fire", + ] + items = [["Strike of the Ninja"]] + self.assertAccessDependency(locations, items) + def test_goal(self) -> None: """Test some different states to verify goal requires the correct items""" self.collect_all_but([*NOTES, "Rescue Phantom"]) diff --git a/worlds/messenger/test/test_shop.py b/worlds/messenger/test/test_shop.py index ee7e82d6cdbe..971ff1763b47 100644 --- a/worlds/messenger/test/test_shop.py +++ b/worlds/messenger/test/test_shop.py @@ -24,25 +24,6 @@ def test_shop_prices(self) -> None: self.assertTrue(loc in SHOP_ITEMS) self.assertEqual(len(prices), len(SHOP_ITEMS)) - def test_dboost(self) -> None: - locations = [ - "Riviere Turquoise Seal - Bounces and Balls", - "Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", - "Sunny Day Mega Shard", "Down Under Mega Shard", - ] - items = [["Path of Resilience", "Meditation", "Second Wind"]] - self.assertAccessDependency(locations, items) - - def test_currents(self) -> None: - self.assertAccessDependency(["Elemental Skylands Seal - Water"], [["Currents Master"]]) - - def test_strike(self) -> None: - locations = [ - "Glacial Peak Seal - Projectile Spike Pit", "Elemental Skylands Seal - Fire", - ] - items = [["Strike of the Ninja"]] - self.assertAccessDependency(locations, items) - class ShopCostMinTest(ShopCostTest): options = { From 863518e420387a2d1b3ffa05575dafbb054b9c69 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 26 Jan 2024 09:19:51 -0600 Subject: [PATCH 113/163] move item classification determination to its own method rather than split between two spots --- worlds/messenger/__init__.py | 73 +++++++++++++++++++++------------- worlds/messenger/subclasses.py | 14 +------ 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 219cd274d8c8..34a8d5431518 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -9,12 +9,12 @@ from worlds.LauncherComponents import Component, Type, components from .client_setup import launch_game from .connections import CONNECTIONS -from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS +from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded from .portals import SHUFFLEABLE_PORTAL_ENTRANCES, add_closed_portal_reqs, disconnect_portals, shuffle_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules -from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices +from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerItem, MessengerRegion components.append( @@ -73,10 +73,10 @@ class MessengerWorld(World): "Money Wrench", ], base_offset)} item_name_groups = { - "Notes": set(NOTES), - "Keys": set(NOTES), - "Crest": {"Sun Crest", "Moon Crest"}, - "Phobe": set(PHOBEKINS), + "Notes": set(NOTES), + "Keys": set(NOTES), + "Crest": {"Sun Crest", "Moon Crest"}, + "Phobe": set(PHOBEKINS), "Phobekin": set(PHOBEKINS), } @@ -86,6 +86,7 @@ class MessengerWorld(World): total_seals: int = 0 required_seals: int = 0 + created_seals: int = 0 total_shards: int = 0 shop_prices: Dict[str, int] figurine_prices: Dict[str, int] @@ -137,10 +138,10 @@ def create_items(self) -> None: self.create_item(item) for item in self.item_name_to_id if item not in - { - "Power Seal", *NOTES, *FIGURINES, *main_movement_items, - *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, - } and "Time Shard" not in item + { + "Power Seal", *NOTES, *FIGURINES, *main_movement_items, + *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, + } and "Time Shard" not in item ] if self.options.limited_movement: @@ -154,8 +155,8 @@ def create_items(self) -> None: notes = [note for note in NOTES if note not in self.multiworld.precollected_items[self.player]] self.random.shuffle(notes) precollected_notes_amount = NotesNeeded.range_end - \ - self.options.notes_needed - \ - (len(NOTES) - len(notes)) + self.options.notes_needed - \ + (len(NOTES) - len(notes)) if precollected_notes_amount: for note in notes[:precollected_notes_amount]: self.multiworld.push_precollected(self.create_item(note)) @@ -172,17 +173,15 @@ def create_items(self) -> None: self.required_seals = int(self.options.percent_seals_required.value / 100 * self.total_seals) seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] - for i in range(self.required_seals): - seals[i].classification = ItemClassification.progression_skip_balancing itempool += seals self.multiworld.itempool += itempool remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) if remaining_fill < 10: self._filler_items = self.random.choices( - list(FILLER)[2:], - weights=list(FILLER.values())[2:], - k=remaining_fill + list(FILLER)[2:], + weights=list(FILLER.values())[2:], + k=remaining_fill ) filler = [self.create_filler() for _ in range(remaining_fill)] @@ -206,12 +205,12 @@ def set_rules(self) -> None: def fill_slot_data(self) -> Dict[str, Any]: slot_data = { - "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, - "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, - "max_price": self.total_shards, - "required_seals": self.required_seals, + "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, + "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, + "max_price": self.total_shards, + "required_seals": self.required_seals, "starting_portals": self.starting_portals, - "portal_exits": self.portal_mapping if self.portal_mapping else [], + "portal_exits": self.portal_mapping if self.portal_mapping else [], **self.options.as_dict("music_box", "death_link", "logic_level"), } return slot_data @@ -227,15 +226,35 @@ def get_filler_item_name(self) -> str: def create_item(self, name: str) -> MessengerItem: item_id: Optional[int] = self.item_name_to_id.get(name, None) - override_prog = getattr(self, "multiworld") is not None and \ - name in {"Windmill Shuriken"} and \ - self.options.logic_level > Logic.option_normal - count = 0 + return MessengerItem( + name, + ItemClassification.progression if item_id is None else self.get_item_classification(name), + item_id, + self.player + ) + + def get_item_classification(self, name: str) -> ItemClassification: if "Time Shard " in name: count = int(name.strip("Time Shard ()")) count = count if count >= 100 else 0 self.total_shards += count - return MessengerItem(name, self.player, item_id, override_prog, count) + return ItemClassification.progression_skip_balancing if count else ItemClassification.filler + + if name == "Windmill Shuriken": + return ItemClassification.progression if self.options.logic_level else ItemClassification.filler + + if name == "Power Seal": + self.created_seals += 1 + return ItemClassification.progression_skip_balancing \ + if self.required_seals >= self.created_seals else ItemClassification.filler + + if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS, *PROG_SHOP_ITEMS}: + return ItemClassification.progression + + if name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}: + return ItemClassification.useful + + return ItemClassification.filler def collect(self, state: "CollectionState", item: "Item") -> bool: change = super().collect(state, item) diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index eeed986cd25f..eead2f6edd87 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -47,7 +47,7 @@ class MessengerLocation(Location): def __init__(self, player: int, name: str, loc_id: Optional[int], parent: MessengerRegion) -> None: super().__init__(player, name, loc_id, parent) if loc_id is None: - self.place_locked_item(MessengerItem(name, parent.player, None)) + self.place_locked_item(MessengerItem(name, ItemClassification.progression, None, parent.player)) class MessengerShopLocation(MessengerLocation): @@ -78,15 +78,3 @@ def access_rule(self, state: CollectionState) -> bool: class MessengerItem(Item): game = "The Messenger" - - def __init__(self, name: str, player: int, item_id: Optional[int] = None, override_progression: bool = False, - count: int = 0) -> None: - if count: - item_class = ItemClassification.progression_skip_balancing - elif item_id is None or override_progression or name in {*NOTES, *PROG_ITEMS, *PHOBEKINS, *PROG_SHOP_ITEMS}: - item_class = ItemClassification.progression - elif name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}: - item_class = ItemClassification.useful - else: - item_class = ItemClassification.filler - super().__init__(name, item_class, item_id, player) From 8f9e9a2ce9000b30e6c04aa15635c1b3b11d1b28 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 26 Jan 2024 10:50:33 -0600 Subject: [PATCH 114/163] super complicated solution for handling installing the alpha builds --- worlds/messenger/client_setup.py | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index b009d60c96e3..4f1cd6233ae6 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -87,7 +87,14 @@ def install_mod() -> None: # TODO: add /latest before actual PR since i want pre-releases for now url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" assets = request_data(url)["assets"] - release_url = assets[-1]["browser_download_url"] + for asset in assets: + if "TheMessengerRandomizerModAP" in asset["name"]: + release_url = asset["browser_download_url"] + break + else: + messagebox("Failure!", "something went wrong while trying to get latest mod version") + logging.error(assets) + return with urllib.request.urlopen(release_url) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: @@ -97,14 +104,38 @@ def install_mod() -> None: def available_mod_update() -> bool: """Check if there's an available update""" - url = "https://raw.githubusercontent.com/alwaysintreble/TheMessengerRandomizerModAP/archipelago/courier.toml" - remote_data = requests.get(url).text - latest_version = remote_data.splitlines()[1].strip("version = \"") + url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" + assets = request_data(url)["assets"] + # TODO simplify once we're done with 0.13.0 alpha + for asset in assets: + if "TheMessengerRandomizerAP" in asset["name"]: + if asset["label"]: + latest_version = asset["label"] + break + names = asset["name"].split("-") + if len(names) > 2: + if names[-1].isnumeric(): + latest_version = names[-1] + break + latest_version = 1 + break + latest_version = names[1] + break + else: + return False toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") with open(toml_path, "r") as f: installed_version = f.read().splitlines()[1].strip("version = \"") + if not installed_version.isnumeric(): + if installed_version[-1].isnumeric(): + installed_version = installed_version[-1] + else: + installed_version = 1 + return latest_version > installed_version + elif latest_version >= 1: + return True return tuplize_version(latest_version) > tuplize_version(installed_version) from . import MessengerWorld From f6ca6780ec46b16bfbf7a1af9281011d22adf348 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 28 Jan 2024 09:59:01 -0600 Subject: [PATCH 115/163] fix logic bugs and add a test --- worlds/messenger/__init__.py | 9 ++++++-- worlds/messenger/constants.py | 1 + worlds/messenger/portals.py | 4 ++-- worlds/messenger/rules.py | 21 ++++++++++++------ worlds/messenger/subclasses.py | 2 ++ worlds/messenger/test/test_access.py | 4 ++-- worlds/messenger/test/test_portals.py | 27 ++++++++++++++++++++++++ worlds/messenger/test/test_shop_chest.py | 2 +- 8 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 worlds/messenger/test/test_portals.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 34a8d5431518..ab5e5d4d3c0b 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,5 +1,5 @@ import logging -from typing import Any, ClassVar, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, TextIO from BaseClasses import CollectionState, Item, ItemClassification, Tutorial from Options import Accessibility @@ -92,6 +92,7 @@ class MessengerWorld(World): figurine_prices: Dict[str, int] _filler_items: List[str] starting_portals: List[str] + spoiler_portal_mapping: Dict[str, str] portal_mapping: List[int] def generate_early(self) -> None: @@ -201,9 +202,13 @@ def set_rules(self) -> None: if self.options.shuffle_portals: disconnect_portals(self) shuffle_portals(self) - # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) + + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: + if self.options.shuffle_portals: + spoiler_handle.write(f"\nPortal Warps:\n{self.spoiler_portal_mapping}") def fill_slot_data(self) -> Dict[str, Any]: + visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, diff --git a/worlds/messenger/constants.py b/worlds/messenger/constants.py index 33d3a83c391e..0c4d6a944cef 100644 --- a/worlds/messenger/constants.py +++ b/worlds/messenger/constants.py @@ -25,6 +25,7 @@ # "Astral Tea Leaves", "Money Wrench", "Candle", + "Seashell", ] PHOBEKINS = [ diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 9a3f841a569b..c2a6df75b61a 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -207,6 +207,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: available_portals = [val for zone in shop_points.values() for val in zone] world.portal_mapping = [] + world.spoiler_portal_mapping = {} for portal in OUTPUT_PORTALS: warp_point = world.random.choice(available_portals) parent = out_to_parent[warp_point] @@ -221,6 +222,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: else: exit_string += f"{warp_point} Checkpoint" world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}2{CHECKPOINTS[parent].index(warp_point)}")) + world.spoiler_portal_mapping[portal] = exit_string connect_portal(world, portal, exit_string) available_portals.remove(warp_point) @@ -255,8 +257,6 @@ def validate_portals(world: "MessengerWorld") -> bool: def add_closed_portal_reqs(world: "MessengerWorld") -> None: closed_portals = [entrance for entrance in SHUFFLEABLE_PORTAL_ENTRANCES if entrance not in world.starting_portals] - if not closed_portals: - return for portal in closed_portals: tower_exit = world.multiworld.get_entrance(f"ToTHQ {portal}", world.player) tower_exit.access_rule = lambda state: state.has(portal, world.player) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 71c56b989945..e56c6c0ec561 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -1,9 +1,9 @@ -from typing import Dict, List, TYPE_CHECKING, Union +from typing import Dict, TYPE_CHECKING from BaseClasses import CollectionState -from worlds.generic.Rules import add_rule, allow_self_locking_items, CollectionRule +from worlds.generic.Rules import CollectionRule, add_rule, allow_self_locking_items from .constants import NOTES, PHOBEKINS -from .options import Logic, MessengerAccessibility +from .options import MessengerAccessibility if TYPE_CHECKING: from . import MessengerWorld @@ -42,6 +42,8 @@ def __init__(self, world: "MessengerWorld") -> None: # Autumn Hills "Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop": lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Autumn Hills - Dimension Climb Shop -> Autumn Hills - Portal": + self.has_vertical, "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Hope Path Shop": self.has_dart, "Autumn Hills - Hope Path Shop -> Autumn Hills - Hope Latch Checkpoint": @@ -72,16 +74,21 @@ def __init__(self, world: "MessengerWorld") -> None: # Howling Grotto "Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop": self.has_wingsuit, - "Howling Grotto - Left -> Bamboo Creek - Right": + "Howling Grotto - Wingsuit Shop -> Howling Grotto - Left": self.has_wingsuit, "Howling Grotto - Wingsuit Shop -> Howling Grotto - Lost Woods Checkpoint": self.has_wingsuit, + "Howling Grotto - Lost Woods Checkpoint -> Howling Grotto - Bottom": + lambda state: state.has("Seashell", self.player), "Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal": lambda state: self.has_wingsuit(state) or self.can_dboost(state), "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop": self.has_wingsuit, "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Crushing Pits Shop": - lambda state: self.has_wingsuit(state) or self.can_dboost(state) or self.can_destroy_projectiles(state), + lambda state: (self.has_wingsuit(state) or self.can_dboost(state) or self.can_destroy_projectiles(state)) + and state.multiworld.get_region("Howling Grotto - Emerald Golem Shop", self.player).can_reach(state), + "Howling Grotto - Emerald Golem Shop -> Howling Grotto - Right": + self.has_wingsuit, # Searing Crags "Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint": self.has_vertical, @@ -178,7 +185,7 @@ def __init__(self, world: "MessengerWorld") -> None: or (self.has_dart(state) and self.has_wingsuit(state)), # Dark Cave "Dark Cave - Right -> Dark Cave - Left": - lambda state: state.has("Candle", self.player), + lambda state: state.has("Candle", self.player) and self.has_dart(state), # Riviere Turquoise "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), @@ -301,7 +308,7 @@ def set_messenger_rules(self) -> None: if loc.name in self.location_rules: loc.access_rule = self.location_rules[loc.name] - multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) + multiworld.completion_condition[self.player] = lambda state: state.has("Do the Thing!", self.player) # if multiworld.accessibility[self.player]: # not locations accessibility # set_self_locking_items(self.world, self.player) diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index eead2f6edd87..b31348d1812b 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -47,6 +47,8 @@ class MessengerLocation(Location): def __init__(self, player: int, name: str, loc_id: Optional[int], parent: MessengerRegion) -> None: super().__init__(player, name, loc_id, parent) if loc_id is None: + if name == "Rescue Phantom": + name = "Do the Thing!" self.place_locked_item(MessengerItem(name, ItemClassification.progression, None, parent.player)) diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py index d91933e78c10..da28f1d19463 100644 --- a/worlds/messenger/test/test_access.py +++ b/worlds/messenger/test/test_access.py @@ -180,9 +180,9 @@ def test_strike(self) -> None: def test_goal(self) -> None: """Test some different states to verify goal requires the correct items""" - self.collect_all_but([*NOTES, "Rescue Phantom"]) + self.collect_all_but([*NOTES, "Do the Thing!"]) self.assertEqual(self.can_reach_location("Rescue Phantom"), False) - self.collect_all_but(["Key of Love", "Rescue Phantom"]) + self.collect_all_but(["Key of Love", "Do the Thing!"]) self.assertBeatable(False) self.collect_by_name(["Key of Love"]) self.assertEqual(self.can_reach_location("Rescue Phantom"), True) diff --git a/worlds/messenger/test/test_portals.py b/worlds/messenger/test/test_portals.py new file mode 100644 index 000000000000..074eba3b3487 --- /dev/null +++ b/worlds/messenger/test/test_portals.py @@ -0,0 +1,27 @@ +from BaseClasses import CollectionState +from . import MessengerTestBase +from .. import MessengerWorld +from ..portals import OUTPUT_PORTALS + + +class PortalTestBase(MessengerTestBase): + def test_portal_reqs(self) -> None: + """tests the paths to open a portal if only that portal is closed with vanilla connections.""" + # portal and requirements to reach it if it's the only closed portal + portal_requirements = { + "Autumn Hills Portal": [["Autumn Hills Portal", "Wingsuit"]], # grotto -> bamboo -> catacombs -> hills + "Riviere Turquoise Portal": [["Riviere Turquoise Portal", "Candle", "Wingsuit", "Rope Dart"]], # hills -> catacombs -> dark cave -> riviere + "Howling Grotto Portal": [["Howling Grotto Portal", "Wingsuit"], ["Howling Grotto Portal", "Meditation", "Second Wind"]], # crags -> quillshroom -> grotto + "Sunken Shrine Portal": [["Sunken Shrine Portal", "Seashell"]], # crags -> quillshroom -> grotto -> shrine + "Searing Crags Portal": [["Searing Crags Portal", "Wingsuit"], ["Searing Crags Portal", "Rope Dart"]], # grotto -> quillshroom -> crags there's two separate paths + "Glacial Peak Portal": [["Glacial Peak Portal", "Wingsuit"], ["Glacial Peak Portal", "Rope Dart"]], # grotto -> quillshroom -> crags -> peak or crags -> peak + } + for portal in OUTPUT_PORTALS: + name = f"{portal} Portal" + entrance_name = f"ToTHQ {name}" + with self.subTest(portal=name, entrance_name=entrance_name): + entrance = self.multiworld.get_entrance(entrance_name, self.player) + # this emulates the portal being initially closed + entrance.access_rule = lambda state: state.has(name, self.player) + self.assertAccessDependency([name], portal_requirements[name], True) + entrance.access_rule = lambda state: True diff --git a/worlds/messenger/test/test_shop_chest.py b/worlds/messenger/test/test_shop_chest.py index ffa250214849..2ac306972614 100644 --- a/worlds/messenger/test/test_shop_chest.py +++ b/worlds/messenger/test/test_shop_chest.py @@ -19,7 +19,7 @@ def test_chest_access(self) -> None: self.assertEqual(self.can_reach_location("Rescue Phantom"), False) self.assertBeatable(False) - self.collect_all_but(["Power Seal", "Rescue Phantom"]) + self.collect_all_but(["Power Seal", "Do the Thing!"]) self.assertEqual(self.can_reach_location("Rescue Phantom"), False) self.assertBeatable(False) self.collect_by_name("Power Seal") From bf753af7ed1875aa7aa37fd61127fa41367e6e31 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 28 Jan 2024 11:26:38 -0600 Subject: [PATCH 116/163] implement logic to shuffle the cutscene portals even though it's probably not possible --- worlds/messenger/__init__.py | 28 +++++++++++++++------------ worlds/messenger/options.py | 2 +- worlds/messenger/portals.py | 26 +++++++++++-------------- worlds/messenger/test/test_portals.py | 4 ++-- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index ab5e5d4d3c0b..c1762122653b 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -11,7 +11,7 @@ from .connections import CONNECTIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded -from .portals import SHUFFLEABLE_PORTAL_ENTRANCES, add_closed_portal_reqs, disconnect_portals, shuffle_portals +from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices @@ -108,11 +108,9 @@ def generate_early(self) -> None: self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) - self.starting_portals = [] - if self.options.available_portals > AvailablePortals.range_start: - # there's 3 specific portals that the game forces open - self.starting_portals = self.random.choices(SHUFFLEABLE_PORTAL_ENTRANCES, - k=self.options.available_portals - 3) + available_portals = ["Riviere Turquoise Portal", "Sunken Shrine Portal", "Searing Crags Portal"] + self.starting_portals = (["Autumn Hills Portal", "Howling Grotto Portal", "Glacial Peak Portal"] + + self.random.sample(available_portals, k=self.options.available_portals - 3)) def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld @@ -204,18 +202,24 @@ def set_rules(self) -> None: shuffle_portals(self) def write_spoiler_header(self, spoiler_handle: TextIO) -> None: + if self.options.available_portals < 6: + spoiler_handle.write(f"\nStarting Portals:\n") + for portal in self.starting_portals: + spoiler_handle.write(f"{portal}\n") if self.options.shuffle_portals: - spoiler_handle.write(f"\nPortal Warps:\n{self.spoiler_portal_mapping}") + spoiler_handle.write(f"\nPortal Warps:\n") + for portal, output in self.spoiler_portal_mapping.items(): + spoiler_handle.write(f"{portal + ' Portal:':33}{output}\n") def fill_slot_data(self) -> Dict[str, Any]: visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) slot_data = { - "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, - "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, - "max_price": self.total_shards, - "required_seals": self.required_seals, + "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, + "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, + "max_price": self.total_shards, + "required_seals": self.required_seals, "starting_portals": self.starting_portals, - "portal_exits": self.portal_mapping if self.portal_mapping else [], + "portal_exits": self.portal_mapping if self.portal_mapping else [], **self.options.as_dict("music_box", "death_link", "logic_level"), } return slot_data diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 11eea49d34fc..1b12a32b195f 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -47,7 +47,7 @@ class EarlyMed(Toggle): class AvailablePortals(Range): """Number of portals that are available from the start. Autumn Hills, Howling Grotto, and Glacial Peak are currently always available. If portal outputs are not randomized, Searing Crags will also be available.""" - display_name = "Number of Available Starting Portals" + display_name = "Available Starting Portals" range_start = 3 range_end = 6 default = 6 diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index c2a6df75b61a..25c0510116c1 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -7,14 +7,7 @@ from . import MessengerWorld -SHUFFLEABLE_PORTAL_ENTRANCES = [ - "Riviere Turquoise Portal", - "Sunken Shrine Portal", - "Searing Crags Portal", -] - - -OUTPUT_PORTALS = [ +PORTALS = [ "Autumn Hills", "Riviere Turquoise", "Howling Grotto", @@ -199,7 +192,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: shuffle_type = world.options.shuffle_portals shop_points = SHOP_POINTS.copy() - for portal in OUTPUT_PORTALS: + for portal in PORTALS: shop_points[portal].append(f"{portal} Portal") if shuffle_type > ShufflePortals.option_shops: shop_points.update(CHECKPOINTS) @@ -208,7 +201,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: world.portal_mapping = [] world.spoiler_portal_mapping = {} - for portal in OUTPUT_PORTALS: + for portal in PORTALS: warp_point = world.random.choice(available_portals) parent = out_to_parent[warp_point] # determine the name of the region of the warp point and save it in our @@ -241,7 +234,7 @@ def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> Non def disconnect_portals(world: "MessengerWorld") -> None: - for portal in OUTPUT_PORTALS: + for portal in PORTALS: entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) entrance.connected_region.entrances.remove(entrance) entrance.connected_region = None @@ -249,14 +242,17 @@ def disconnect_portals(world: "MessengerWorld") -> None: def validate_portals(world: "MessengerWorld") -> bool: new_state = CollectionState(world.multiworld) - for loc in set(world.multiworld.get_locations(world.player)): - if loc.can_reach(new_state): + new_state.update_reachable_regions(world.player) + reachable_locs = 0 + for loc in world.multiworld.get_locations(world.player): + reachable_locs += loc.can_reach(new_state) + if reachable_locs > 5: return True return False def add_closed_portal_reqs(world: "MessengerWorld") -> None: - closed_portals = [entrance for entrance in SHUFFLEABLE_PORTAL_ENTRANCES if entrance not in world.starting_portals] + closed_portals = [entrance for entrance in PORTALS if f"{entrance} Portal" not in world.starting_portals] for portal in closed_portals: - tower_exit = world.multiworld.get_entrance(f"ToTHQ {portal}", world.player) + tower_exit = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) tower_exit.access_rule = lambda state: state.has(portal, world.player) diff --git a/worlds/messenger/test/test_portals.py b/worlds/messenger/test/test_portals.py index 074eba3b3487..1b1c7b3eb1b0 100644 --- a/worlds/messenger/test/test_portals.py +++ b/worlds/messenger/test/test_portals.py @@ -1,7 +1,7 @@ from BaseClasses import CollectionState from . import MessengerTestBase from .. import MessengerWorld -from ..portals import OUTPUT_PORTALS +from ..portals import PORTALS class PortalTestBase(MessengerTestBase): @@ -16,7 +16,7 @@ def test_portal_reqs(self) -> None: "Searing Crags Portal": [["Searing Crags Portal", "Wingsuit"], ["Searing Crags Portal", "Rope Dart"]], # grotto -> quillshroom -> crags there's two separate paths "Glacial Peak Portal": [["Glacial Peak Portal", "Wingsuit"], ["Glacial Peak Portal", "Rope Dart"]], # grotto -> quillshroom -> crags -> peak or crags -> peak } - for portal in OUTPUT_PORTALS: + for portal in PORTALS: name = f"{portal} Portal" entrance_name = f"ToTHQ {name}" with self.subTest(portal=name, entrance_name=entrance_name): From 866e0e4936e973413b6366de47c8dfe9ca85dbb3 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 28 Jan 2024 11:30:03 -0600 Subject: [PATCH 117/163] just use the one list --- worlds/messenger/__init__.py | 6 +++--- worlds/messenger/portals.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index c1762122653b..08b401cdfec6 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -108,9 +108,9 @@ def generate_early(self) -> None: self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) - available_portals = ["Riviere Turquoise Portal", "Sunken Shrine Portal", "Searing Crags Portal"] - self.starting_portals = (["Autumn Hills Portal", "Howling Grotto Portal", "Glacial Peak Portal"] + - self.random.sample(available_portals, k=self.options.available_portals - 3)) + self.starting_portals = [f"{portal} Portal" + for portal in PORTALS[:3] + + self.random.sample(PORTALS[3:], k=self.options.available_portals - 3)] def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 25c0510116c1..1f4cbdca9373 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -9,11 +9,11 @@ PORTALS = [ "Autumn Hills", - "Riviere Turquoise", "Howling Grotto", + "Glacial Peak", + "Riviere Turquoise", "Sunken Shrine", "Searing Crags", - "Glacial Peak", ] From 7dcccc83da2d87cb967a2c42df5d0df99d811d21 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 29 Jan 2024 15:30:39 -0600 Subject: [PATCH 118/163] fix some issues with the mod checking/downloading --- worlds/messenger/client_setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 4f1cd6233ae6..2474a14a9045 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -105,7 +105,7 @@ def install_mod() -> None: def available_mod_update() -> bool: """Check if there's an available update""" url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" - assets = request_data(url)["assets"] + assets = request_data(url)[0]["assets"] # TODO simplify once we're done with 0.13.0 alpha for asset in assets: if "TheMessengerRandomizerAP" in asset["name"]: @@ -133,8 +133,8 @@ def available_mod_update() -> bool: installed_version = installed_version[-1] else: installed_version = 1 - return latest_version > installed_version - elif latest_version >= 1: + return int(latest_version) > int(installed_version) + elif int(latest_version) >= 1: return True return tuplize_version(latest_version) > tuplize_version(installed_version) From 96f7fb5cec6c85efce56cab87f6dead20f98226e Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 29 Jan 2024 17:24:21 -0600 Subject: [PATCH 119/163] Core: have webhost slot name links go through the launcher so that components can use them --- CommonClient.py | 3 ++- Launcher.py | 34 ++++++++++++++++++++++++++++---- WebHostLib/templates/macros.html | 2 +- inno_setup.iss | 4 ++-- worlds/LauncherComponents.py | 11 ++++++++--- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 736cf4922f40..038a856a3287 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -885,7 +885,8 @@ def get_base_parser(description: typing.Optional[str] = None): return parser -def run_as_textclient(): +def run_as_textclient(*args): + logger.info(args) class TextContext(CommonContext): # Text Mode to use !hint and such with games that have no text entry tags = CommonContext.tags | {"TextOnly"} diff --git a/Launcher.py b/Launcher.py index 9e184bf1088d..06b131c029cb 100644 --- a/Launcher.py +++ b/Launcher.py @@ -16,10 +16,11 @@ import shlex import subprocess import sys +import urllib.parse import webbrowser from os.path import isfile from shutil import which -from typing import Sequence, Union, Optional +from typing import Sequence, Tuple, Union, Optional import Utils import settings @@ -107,9 +108,24 @@ def update_settings(): ]) -def identify(path: Union[None, str]): +def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: if path is None: return None, None + if path.startswith("archipelago://"): + logging.info("found uri") + queries = urllib.parse.parse_qs(path) + if "game" in queries: + game = urllib.parse.parse_qs(path)["game"][0] + else: # TODO around 0.5.0 - this is for pre this change webhost uri's + game = "Archipelago" + logging.info(game) + for component in components: + if component.supports_uri and component.game_name == game: + return path, component + elif component.display_name == "Text Client": + # fallback + text_client_component = component + return path, text_client_component for component in components: if component.handles_file(path): return path, component @@ -253,6 +269,15 @@ def run_component(component: Component, *args): logging.warning(f"Component {component} does not appear to be executable.") +def find_component(game: str) -> Component: + for component in components: + if component.game_name and component.game_name == game: + return component + elif component.display_name == "Text Client": + text_client_component = component + return text_client_component + + def main(args: Optional[Union[argparse.Namespace, dict]] = None): if isinstance(args, argparse.Namespace): args = {k: v for k, v in args._get_kwargs()} @@ -268,11 +293,12 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): if not component: logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") + if args["update_settings"]: update_settings() - if 'file' in args: + if "file" in args: run_component(args["component"], args["file"], *args["args"]) - elif 'component' in args: + elif "component" in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: run_gui() diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index 0722ee317466..71f37a2224dc 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -22,7 +22,7 @@ {% for patch in room.seed.slots|list|sort(attribute="player_id") %} {{ patch.player_id }} - {{ patch.player_name }} + {{ patch.player_name }} {{ patch.game }} {% if patch.data %} diff --git a/inno_setup.iss b/inno_setup.iss index be5de320a1c6..2399c9e7ff19 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -183,8 +183,8 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{ Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; -Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; -Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; +Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; +Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; [Code] // See: https://stackoverflow.com/a/51614652/2287576 diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 03c89b75ff11..2a49f31a39ab 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -23,10 +23,13 @@ class Component: cli: bool func: Optional[Callable] file_identifier: Optional[Callable[[str], bool]] + game_name: Optional[str] + supports_uri: Optional[bool] def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None, cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None, - func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None): + func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None, + game_name: Optional[str] = None, supports_uri: Optional[bool] = False): self.display_name = display_name self.script_name = script_name self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None @@ -42,6 +45,8 @@ def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_ Type.ADJUSTER if "Adjuster" in display_name else Type.MISC) self.func = func self.file_identifier = file_identifier + self.game_name = game_name + self.supports_uri = supports_uri def handles_file(self, path: str): return self.file_identifier(path) if self.file_identifier else False @@ -72,9 +77,9 @@ def __call__(self, path: str): return False -def launch_textclient(): +def launch_textclient(*args): import CommonClient - launch_subprocess(CommonClient.run_as_textclient, name="TextClient") + launch_subprocess(CommonClient.run_as_textclient(*args), name="TextClient") components: List[Component] = [ From 09f5e4cc64896dc11a6242a1de9273259f22dcf1 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 29 Jan 2024 20:41:37 -0600 Subject: [PATCH 120/163] add uri support to the launcher component function --- worlds/messenger/__init__.py | 2 +- worlds/messenger/client_setup.py | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 08b401cdfec6..9e392aa701de 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -18,7 +18,7 @@ from .subclasses import MessengerItem, MessengerRegion components.append( - Component("The Messenger", component_type=Type.CLIENT, func=launch_game) + Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True) ) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 2474a14a9045..91b4ea937da7 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -2,10 +2,12 @@ import logging import os.path import subprocess +import sys import urllib.request +from pathlib import Path from shutil import which from tkinter.messagebox import askyesnocancel -from typing import Any +from typing import Any, Optional from zipfile import ZipFile import requests @@ -13,7 +15,7 @@ from Utils import is_linux, is_windows, messagebox, tuplize_version -def launch_game() -> None: +def launch_game(url: Optional[str] = None) -> None: """Check the game installation, then launch it""" if not (is_linux or is_windows): return @@ -26,10 +28,10 @@ def mod_installed() -> bool: """Check if the mod is installed""" return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) - def request_data(url: str) -> Any: + def request_data(request_url: str) -> Any: """Fetches json response from given url""" - logging.info(f"requesting {url}") - response = requests.get(url) + logging.info(f"requesting {request_url}") + response = requests.get(request_url) if response.status_code == 200: # success try: data = response.json() @@ -158,6 +160,12 @@ def available_mod_update() -> bool: "Old mod version detected. Would you like to update now?") if should_update: install_mod() - if is_linux and not which("mono"): # don't launch the game if we're on steam deck - return - os.startfile("steam://rungameid/764790") + logging.info(url) + if is_linux: + os.startfile("steam://rungameid/764790") + else: + os.chdir(Path(MessengerWorld.settings.game_path).parent) + if url: + subprocess.Popen([MessengerWorld.settings.game_path, str(url)]) + else: + subprocess.Popen(MessengerWorld.settings.game_path) From 244d970e0e7f466c2e18963f0cc4aec7dcb15f26 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 30 Jan 2024 19:45:34 -0600 Subject: [PATCH 121/163] generate output file under specific conditions --- worlds/messenger/__init__.py | 14 +++++++++++++- worlds/messenger/rules.py | 5 +++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 9e392aa701de..bf971d6fe268 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -3,7 +3,7 @@ from BaseClasses import CollectionState, Item, ItemClassification, Tutorial from Options import Accessibility -from Utils import visualize_regions +from Utils import visualize_regions, output_path from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components @@ -211,6 +211,18 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: for portal, output in self.spoiler_portal_mapping.items(): spoiler_handle.write(f"{portal + ' Portal:':33}{output}\n") + def generate_output(self, output_directory: str) -> None: + out_path = output_path(self.multiworld.get_out_file_name_base(self.player) + ".aptm") + if self.multiworld.players > 1 or "The Messenger\\Archipelago\\output" not in out_path: + return + from json import dump + data = { + "slot_data": self.fill_slot_data(), + "loc_data": {loc.address: {loc.item.code: loc.item.name} for loc in self.multiworld.get_filled_locations() if loc.address}, + } + with open(out_path, "w") as f: + dump(data, f) + def fill_slot_data(self) -> Dict[str, Any]: visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) slot_data = { diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index e56c6c0ec561..a22593579f93 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -34,8 +34,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Artificer's Portal": lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player), "Shrink Down": - lambda state: (state.has_all(NOTES, self.player) or self.has_enough_seals(state)) - and self.has_dart(state), + lambda state: state.has_all(NOTES, self.player) or self.has_enough_seals(state), # the shop "Money Sink": lambda state: state.has("Money Wrench", self.player) and self.can_shop(state), @@ -308,6 +307,8 @@ def set_messenger_rules(self) -> None: if loc.name in self.location_rules: loc.access_rule = self.location_rules[loc.name] + if self.world.options.music_box and not self.world.options.limited_movement: + add_rule(multiworld.get_entrance("Shrink Down", self.player), self.has_dart) multiworld.completion_condition[self.player] = lambda state: state.has("Do the Thing!", self.player) # if multiworld.accessibility[self.player]: # not locations accessibility # set_self_locking_items(self.world, self.player) From af5af4e4a465597c85a65df6adf20e71125aeffe Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 30 Jan 2024 20:13:05 -0600 Subject: [PATCH 122/163] cleanup connections.py --- worlds/messenger/connections.py | 1539 +++++++++++-------------------- 1 file changed, 561 insertions(+), 978 deletions(-) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 265fd9e7c733..a333c55d4120 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -1,1011 +1,594 @@ from typing import Dict, List - -CONNECTIONS: Dict[str, Dict[str, Dict[str, List[str]]]] = { +CONNECTIONS: Dict[str, Dict[str, List[str]]] = { "Ninja Village": { - "Right": { - "exits": [ - "Autumn Hills - Left", - "Ninja Village - Nest", - ], - "rules": ["True"], - }, - "Nest": { - "exits": [ - "Ninja Village - Right", - ] - } + "Right": [ + "Autumn Hills - Left", + "Ninja Village - Nest", + ], + "Nest": [ + "Ninja Village - Right", + ], }, "Autumn Hills": { - "Left": { - "exits": [ - "Ninja Village - Right", - "Autumn Hills - Climbing Claws Shop", - ], - "rules": ["True"], - }, - "Right": { - "exits": [ - "Forlorn Temple - Left", - "Autumn Hills - Leaf Golem Shop", - ], - "rules": ["True", "True"], - }, - "Bottom": { - "exits": [ - "Catacombs - Bottom Left", - "Autumn Hills - Double Swing Checkpoint", - ], - "rules": ["True"], - }, - "Portal": { - "exits": [ - "Tower HQ", - "Autumn Hills - Dimension Climb Shop", - ], - "rules": ["True", "Wingsuit, Rope Dart"], - }, - "Climbing Claws Shop": { - "exits": [ - "Autumn Hills - Left", - "Autumn Hills - Hope Path Shop", - "Autumn Hills - Lakeside Checkpoint", - ], - "rules": ["True", "True"], - }, - "Hope Path Shop": { - "exits": [ - "Autumn Hills - Climbing Claws Shop", - "Autumn Hills - Hope Latch Checkpoint", - "Autumn Hills - Lakeside Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Dimension Climb Shop": { - "exits": [ - "Autumn Hills - Lakeside Checkpoint", - "Autumn Hills - Portal", - "Autumn Hills - Double Swing Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Leaf Golem Shop": { - "exits": [ - "Autumn Hills - Spike Ball Swing Checkpoint", - "Autumn Hills - Right", - ], - "rules": ["True", "True"], - }, - "Hope Latch Checkpoint": { - "exits": [ - "Autumn Hills - Hope Path Shop", - "Autumn Hills - Key of Hope Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Key of Hope Checkpoint": { - "exits": [ - "Autumn Hills - Hope Latch Checkpoint", - "Autumn Hills - Lakeside Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Lakeside Checkpoint": { - "exits": [ - "Autumn Hills - Climbing Claws Shop", - "Autumn Hills - Dimension Climb Shop", - ], - "rules": ["True", "True", "True"], - }, - "Double Swing Checkpoint": { - "exits": [ - "Autumn Hills - Dimension Climb Shop", - "Autumn Hills - Spike Ball Swing Checkpoint", - "Autumn Hills - Bottom", - ], - "rules": ["True", "True", "True"], - }, - "Spike Ball Swing Checkpoint": { - "exits": [ - "Autumn Hills - Double Swing Checkpoint", - "Autumn Hills - Leaf Golem Shop", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Ninja Village - Right", + "Autumn Hills - Climbing Claws Shop", + ], + "Right": [ + "Forlorn Temple - Left", + "Autumn Hills - Leaf Golem Shop", + ], + "Bottom": [ + "Catacombs - Bottom Left", + "Autumn Hills - Double Swing Checkpoint", + ], + "Portal": [ + "Tower HQ", + "Autumn Hills - Dimension Climb Shop", + ], + "Climbing Claws Shop": [ + "Autumn Hills - Left", + "Autumn Hills - Hope Path Shop", + "Autumn Hills - Lakeside Checkpoint", + ], + "Hope Path Shop": [ + "Autumn Hills - Climbing Claws Shop", + "Autumn Hills - Hope Latch Checkpoint", + "Autumn Hills - Lakeside Checkpoint", + ], + "Dimension Climb Shop": [ + "Autumn Hills - Lakeside Checkpoint", + "Autumn Hills - Portal", + "Autumn Hills - Double Swing Checkpoint", + ], + "Leaf Golem Shop": [ + "Autumn Hills - Spike Ball Swing Checkpoint", + "Autumn Hills - Right", + ], + "Hope Latch Checkpoint": [ + "Autumn Hills - Hope Path Shop", + "Autumn Hills - Key of Hope Checkpoint", + ], + "Key of Hope Checkpoint": [ + "Autumn Hills - Hope Latch Checkpoint", + "Autumn Hills - Lakeside Checkpoint", + ], + "Lakeside Checkpoint": [ + "Autumn Hills - Climbing Claws Shop", + "Autumn Hills - Dimension Climb Shop", + ], + "Double Swing Checkpoint": [ + "Autumn Hills - Dimension Climb Shop", + "Autumn Hills - Spike Ball Swing Checkpoint", + "Autumn Hills - Bottom", + ], + "Spike Ball Swing Checkpoint": [ + "Autumn Hills - Double Swing Checkpoint", + "Autumn Hills - Leaf Golem Shop", + ], }, "Forlorn Temple": { - "Left": { - "exits": [ - "Autumn Hills - Right", - "Forlorn Temple - Outside Shop", - ], - "rules": ["True", "True", "True"], - }, - "Right": { - "exits": [ - "Bamboo Creek - Top Left", - "Forlorn Temple - Demon King Shop", - ], - "rules": ["True", "True", "True"], - }, - "Bottom": { - "exits": [ - "Catacombs - Top Left", - "Forlorn Temple - Outside Shop", - ], - "rules": ["True", "True", "True"], - }, - "Outside Shop": { - "exits": [ - "Forlorn Temple - Left", - "Forlorn Temple - Bottom", - "Forlorn Temple - Entrance Shop", - ], - "rules": ["True", "True", "True"], - }, - "Entrance Shop": { - "exits": [ - "Forlorn Temple - Outside Shop", - "Forlorn Temple - Sunny Day Checkpoint", - ], - "rules": ["True", "True"], - }, - "Climb Shop": { - "exits": [ - "Forlorn Temple - Rocket Maze Checkpoint", - "Forlorn Temple - Rocket Sunset Shop", - ], - "rules": ["True", "True", "True"], - }, - "Rocket Sunset Shop": { - "exits": [ - "Forlorn Temple - Climb Shop", - "Forlorn Temple - Descent Shop", - ], - "rules": ["True", "True", "True"], - }, - "Descent Shop": { - "exits": [ - "Forlorn Temple - Rocket Sunset Shop", - "Forlorn Temple - Saw Gauntlet Shop", - ], - "rules": ["True", "True", "True"], - }, - "Saw Gauntlet Shop": { - "exits": [ - "Forlorn Temple - Demon King Shop", - ], - "rules": ["True", "True", "True"], - }, - "Demon King Shop": { - "exits": [ - "Forlorn Temple - Saw Gauntlet Shop", - "Forlorn Temple - Right", - ], - "rules": ["True", "True", "True"], - }, - "Sunny Day Checkpoint": { - "exits": [ - "Forlorn Temple - Rocket Maze Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Rocket Maze Checkpoint": { - "exits": [ - "Forlorn Temple - Sunny Day Checkpoint", - "Forlorn Temple - Climb Shop", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Autumn Hills - Right", + "Forlorn Temple - Outside Shop", + ], + "Right": [ + "Bamboo Creek - Top Left", + "Forlorn Temple - Demon King Shop", + ], + "Bottom": [ + "Catacombs - Top Left", + "Forlorn Temple - Outside Shop", + ], + "Outside Shop": [ + "Forlorn Temple - Left", + "Forlorn Temple - Bottom", + "Forlorn Temple - Entrance Shop", + ], + "Entrance Shop": [ + "Forlorn Temple - Outside Shop", + "Forlorn Temple - Sunny Day Checkpoint", + ], + "Climb Shop": [ + "Forlorn Temple - Rocket Maze Checkpoint", + "Forlorn Temple - Rocket Sunset Shop", + ], + "Rocket Sunset Shop": [ + "Forlorn Temple - Climb Shop", + "Forlorn Temple - Descent Shop", + ], + "Descent Shop": [ + "Forlorn Temple - Rocket Sunset Shop", + "Forlorn Temple - Saw Gauntlet Shop", + ], + "Saw Gauntlet Shop": [ + "Forlorn Temple - Demon King Shop", + ], + "Demon King Shop": [ + "Forlorn Temple - Saw Gauntlet Shop", + "Forlorn Temple - Right", + ], + "Sunny Day Checkpoint": [ + "Forlorn Temple - Rocket Maze Checkpoint", + ], + "Rocket Maze Checkpoint": [ + "Forlorn Temple - Sunny Day Checkpoint", + "Forlorn Temple - Climb Shop", + ], }, "Catacombs": { - "Top Left": { - "exits": [ - "Forlorn Temple - Bottom", - "Catacombs - Triple Spike Crushers Shop", - ], - "rules": ["True", "True", "True"], - }, - "Bottom Left": { - "exits": [ - "Autumn Hills - Bottom", - "Catacombs - Triple Spike Crushers Shop", - "Catacombs - Death Trap Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Bottom": { - "exits": [ - "Dark Cave - Right", - "Catacombs - Dirty Pond Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Right": { - "exits": [ - "Bamboo Creek - Bottom Left", - "Catacombs - Ruxxtin Shop", - ], - "rules": ["True", "True", "True"], - }, - "Triple Spike Crushers Shop": { - "exits": [ - "Catacombs - Bottom Left", - "Catacombs - Death Trap Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Ruxxtin Shop": { - "exits": [ - "Catacombs - Right", - "Catacombs - Dirty Pond Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Death Trap Checkpoint": { - "exits": [ - "Catacombs - Triple Spike Crushers Shop", - "Catacombs - Bottom Left", - "Catacombs - Dirty Pond Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Crusher Gauntlet Checkpoint": { - "exits": [ - "Catacombs - Dirty Pond Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Dirty Pond Checkpoint": { - "exits": [ - "Catacombs - Bottom", - "Catacombs - Death Trap Checkpoint", - "Catacombs - Crusher Gauntlet Checkpoint", - "Catacombs - Ruxxtin Shop", - ], - "rules": ["True", "True", "True", "True"], - }, + "Top Left": [ + "Forlorn Temple - Bottom", + "Catacombs - Triple Spike Crushers Shop", + ], + "Bottom Left": [ + "Autumn Hills - Bottom", + "Catacombs - Triple Spike Crushers Shop", + "Catacombs - Death Trap Checkpoint", + ], + "Bottom": [ + "Dark Cave - Right", + "Catacombs - Dirty Pond Checkpoint", + ], + "Right": [ + "Bamboo Creek - Bottom Left", + "Catacombs - Ruxxtin Shop", + ], + "Triple Spike Crushers Shop": [ + "Catacombs - Bottom Left", + "Catacombs - Death Trap Checkpoint", + ], + "Ruxxtin Shop": [ + "Catacombs - Right", + "Catacombs - Dirty Pond Checkpoint", + ], + "Death Trap Checkpoint": [ + "Catacombs - Triple Spike Crushers Shop", + "Catacombs - Bottom Left", + "Catacombs - Dirty Pond Checkpoint", + ], + "Crusher Gauntlet Checkpoint": [ + "Catacombs - Dirty Pond Checkpoint", + ], + "Dirty Pond Checkpoint": [ + "Catacombs - Bottom", + "Catacombs - Death Trap Checkpoint", + "Catacombs - Crusher Gauntlet Checkpoint", + "Catacombs - Ruxxtin Shop", + ], }, "Bamboo Creek": { - "Bottom Left": { - "exits": [ - "Catacombs - Right", - "Bamboo Creek - Spike Crushers Shop", - ], - "rules": ["True", "True", "True"], - }, - "Top Left": { - "exits": [ - "Bamboo Creek - Abandoned Shop", - ], - "rules": ["True", "True", "True"], - }, - "Right": { - "exits": [ - "Howling Grotto - Left", - "Bamboo Creek - Time Loop Shop", - ], - "rules": ["True", "True", "True"], - }, - "Spike Crushers Shop": { - "exits": [ - "Bamboo Creek - Bottom Left", - "Bamboo Creek - Abandoned Shop", - ], - "rules": ["True", "True", "True"], - }, - "Abandoned Shop": { - "exits": [ - "Bamboo Creek - Top Left", - "Bamboo Creek - Spike Crushers Shop", - "Bamboo Creek - Spike Doors Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Time Loop Shop": { - "exits": [ - "Bamboo Creek - Right", - "Bamboo Creek - Spike Doors Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Spike Ball Pits Checkpoint": { - "exits": [ - "Bamboo Creek - Spike Doors Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Spike Doors Checkpoint": { - "exits": [ - "Bamboo Creek - Abandoned Shop", - "Bamboo Creek - Spike Ball Pits Checkpoint", - "Bamboo Creek - Time Loop Shop", - ], - "rules": ["True", "True", "True"], - }, + "Bottom Left": [ + "Catacombs - Right", + "Bamboo Creek - Spike Crushers Shop", + ], + "Top Left": [ + "Bamboo Creek - Abandoned Shop", + ], + "Right": [ + "Howling Grotto - Left", + "Bamboo Creek - Time Loop Shop", + ], + "Spike Crushers Shop": [ + "Bamboo Creek - Bottom Left", + "Bamboo Creek - Abandoned Shop", + ], + "Abandoned Shop": [ + "Bamboo Creek - Top Left", + "Bamboo Creek - Spike Crushers Shop", + "Bamboo Creek - Spike Doors Checkpoint", + ], + "Time Loop Shop": [ + "Bamboo Creek - Right", + "Bamboo Creek - Spike Doors Checkpoint", + ], + "Spike Ball Pits Checkpoint": [ + "Bamboo Creek - Spike Doors Checkpoint", + ], + "Spike Doors Checkpoint": [ + "Bamboo Creek - Abandoned Shop", + "Bamboo Creek - Spike Ball Pits Checkpoint", + "Bamboo Creek - Time Loop Shop", + ], }, "Howling Grotto": { - "Left": { - "exits": [ - "Bamboo Creek - Right", - "Howling Grotto - Wingsuit Shop", - ], - "rules": ["Wingsuit", "True"], - }, - "Top": { - "exits": [ - "Howling Grotto - Crushing Pits Shop", - "Quillshroom Marsh - Bottom Left", - ], - "rules": ["True", "True"], - }, - "Right": { - "exits": [ - "Howling Grotto - Emerald Golem Shop", - "Quillshroom Marsh - Top Left", - ], - "rules": ["True", "True"], - }, - "Bottom": { - "exits": [ - "Howling Grotto - Lost Woods Checkpoint", - "Sunken Shrine - Left", - ], - "rules": ["True", "True"], - }, - "Portal": { - "exits": [ - "Howling Grotto - Crushing Pits Shop", - "Tower HQ", - ], - "rules": ["True", "True", "True"], - }, - "Wingsuit Shop": { - "exits": [ - "Howling Grotto - Left", - "Howling Grotto - Lost Woods Checkpoint", - ], - "rules": ["Wingsuit", "True"], - }, - "Crushing Pits Shop": { - "exits": [ - "Howling Grotto - Lost Woods Checkpoint", - "Howling Grotto - Portal", - "Howling Grotto - Breezy Crushers Checkpoint", - "Howling Grotto - Top", - ], - "rules": ["True", "True", "True"], - }, - "Emerald Golem Shop": { - "exits": [ - "Howling Grotto - Breezy Crushers Checkpoint", - "Howling Grotto - Right", - ], - "rules": ["True", "True", "True"], - }, - "Lost Woods Checkpoint": { - "exits": [ - "Howling Grotto - Wingsuit Shop", - "Howling Grotto - Crushing Pits Shop", - "Howling Grotto - Bottom", - ], - "rules": ["True", "True", "True"], - }, - "Breezy Crushers Checkpoint": { - "exits": [ - "Howling Grotto - Crushing Pits Shop", - "Howling Grotto - Emerald Golem Shop", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Bamboo Creek - Right", + "Howling Grotto - Wingsuit Shop", + ], + "Top": [ + "Howling Grotto - Crushing Pits Shop", + "Quillshroom Marsh - Bottom Left", + ], + "Right": [ + "Howling Grotto - Emerald Golem Shop", + "Quillshroom Marsh - Top Left", + ], + "Bottom": [ + "Howling Grotto - Lost Woods Checkpoint", + "Sunken Shrine - Left", + ], + "Portal": [ + "Howling Grotto - Crushing Pits Shop", + "Tower HQ", + ], + "Wingsuit Shop": [ + "Howling Grotto - Left", + "Howling Grotto - Lost Woods Checkpoint", + ], + "Crushing Pits Shop": [ + "Howling Grotto - Lost Woods Checkpoint", + "Howling Grotto - Portal", + "Howling Grotto - Breezy Crushers Checkpoint", + "Howling Grotto - Top", + ], + "Emerald Golem Shop": [ + "Howling Grotto - Breezy Crushers Checkpoint", + "Howling Grotto - Right", + ], + "Lost Woods Checkpoint": [ + "Howling Grotto - Wingsuit Shop", + "Howling Grotto - Crushing Pits Shop", + "Howling Grotto - Bottom", + ], + "Breezy Crushers Checkpoint": [ + "Howling Grotto - Crushing Pits Shop", + "Howling Grotto - Emerald Golem Shop", + ], }, "Quillshroom Marsh": { - "Top Left": { - "exits": [ - "Howling Grotto - Right", - "Quillshroom Marsh - Seashell Checkpoint", - "Quillshroom Marsh - Spikey Window Shop", - ], - "rules": ["True", "True", "True"], - }, - "Bottom Left": { - "exits": [ - "Howling Grotto - Top", - "Quillshroom Marsh - Sand Trap Shop", - "Quillshroom Marsh - Bottom Right", - ], - "rules": ["True", "True", "True"], - }, - "Top Right": { - "exits": [ - "Quillshroom Marsh - Queen of Quills Shop", - "Searing Crags - Left", - ], - "rules": ["True", "True", "True"], - }, - "Bottom Right": { - "exits": [ - "Quillshroom Marsh - Bottom Left", - "Quillshroom Marsh - Sand Trap Shop", - "Searing Crags - Bottom", - ], - "rules": ["True", "True", "True"], - }, - "Spikey Window Shop": { - "exits": [ - "Quillshroom Marsh - Top Left", - "Quillshroom Marsh - Seashell Checkpoint", - "Quillshroom Marsh - Quicksand Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Sand Trap Shop": { - "exits": [ - "Quillshroom Marsh - Quicksand Checkpoint", - "Quillshroom Marsh - Bottom Left", - "Quillshroom Marsh - Bottom Right", - "Quillshroom Marsh - Spike Wave Checkpoint", - ], - "rules": ["True", "True", "True", "True"], - }, - "Queen of Quills Shop": { - "exits": [ - "Quillshroom Marsh - Spike Wave Checkpoint", - "Quillshroom Marsh - Top Right", - ], - "rules": ["True", "True", "True"], - }, - "Seashell Checkpoint": { - "exits": [ - "Quillshroom Marsh - Top Left", - "Quillshroom Marsh - Spikey Window Shop", - ], - "rules": ["True", "True", "True"], - }, - "Quicksand Checkpoint": { - "exits": [ - "Quillshroom Marsh - Spikey Window Shop", - "Quillshroom Marsh - Sand Trap Shop", - ], - "rules": ["True", "True", "True"], - }, - "Spike Wave Checkpoint": { - "exits": [ - "Quillshroom Marsh - Sand Trap Shop", - "Quillshroom Marsh - Queen of Quills Shop", - ], - "rules": ["True", "True", "True"], - }, + "Top Left": [ + "Howling Grotto - Right", + "Quillshroom Marsh - Seashell Checkpoint", + "Quillshroom Marsh - Spikey Window Shop", + ], + "Bottom Left": [ + "Howling Grotto - Top", + "Quillshroom Marsh - Sand Trap Shop", + "Quillshroom Marsh - Bottom Right", + ], + "Top Right": [ + "Quillshroom Marsh - Queen of Quills Shop", + "Searing Crags - Left", + ], + "Bottom Right": [ + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Sand Trap Shop", + "Searing Crags - Bottom", + ], + "Spikey Window Shop": [ + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Seashell Checkpoint", + "Quillshroom Marsh - Quicksand Checkpoint", + ], + "Sand Trap Shop": [ + "Quillshroom Marsh - Quicksand Checkpoint", + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Bottom Right", + "Quillshroom Marsh - Spike Wave Checkpoint", + ], + "Queen of Quills Shop": [ + "Quillshroom Marsh - Spike Wave Checkpoint", + "Quillshroom Marsh - Top Right", + ], + "Seashell Checkpoint": [ + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Spikey Window Shop", + ], + "Quicksand Checkpoint": [ + "Quillshroom Marsh - Spikey Window Shop", + "Quillshroom Marsh - Sand Trap Shop", + ], + "Spike Wave Checkpoint": [ + "Quillshroom Marsh - Sand Trap Shop", + "Quillshroom Marsh - Queen of Quills Shop", + ], }, "Searing Crags": { - "Left": { - "exits": [ - "Quillshroom Marsh - Top Right", - "Searing Crags - Rope Dart Shop", - ], - "rules": ["True", "True", "True"], - }, - "Top": { - "exits": [ - "Searing Crags - Colossuses Shop", - "Glacial Peak - Bottom", - ], - "rules": ["True", "True"], - }, - "Bottom": { - "exits": [ - "Searing Crags - Portal", - "Quillshroom Marsh - Bottom Right", - ], - "rules": ["True", "True"], - }, - "Right": { - "exits": [ - "Searing Crags - Portal", - "Underworld - Left", - ], - "rules": ["True", "True"], - }, - "Portal": { - "exits": [ - "Searing Crags - Bottom", - "Searing Crags - Right", - "Searing Crags - Before Final Climb Shop", - "Searing Crags - Colossuses Shop", - "Tower HQ", - ], - "rules": ["True", "Lightfoot Tabi", "Wingsuit", "Wingsuit", "True"], - }, - "Rope Dart Shop": { - "exits": [ - "Searing Crags - Left", - "Searing Crags - Triple Ball Spinner Checkpoint", - ], - "rules": ["True", ["Wingsuit", "Rope Dart"]], - }, - "Falling Rocks Shop": { - "exits": [ - "Searing Crags - Triple Ball Spinner Checkpoint", - "Searing Crags - Searing Mega Shard Shop", - ], - "rules": ["True", "True"], - }, - "Searing Mega Shard Shop": { - "exits": [ - "Searing Crags - Falling Rocks Shop", - "Searing Crags - Before Final Climb Shop", - ], - "rules": ["True", "True"], - }, - "Before Final Climb Shop": { - "exits": [ - "Searing Crags - Raining Rocks Checkpoint", - "Searing Crags - Portal", - "Searing Crags - Colossuses Shop", - ], - "rules": ["True", "True", "True"], - }, - "Colossuses Shop": { - "exits": [ - "Searing Crags - Before Final Climb Shop", - "Searing Crags - Key of Strength Shop", - "Searing Crags - Portal", - "Searing Crags - Top", - ], - "rules": ["True", ["Power Thistle, [Rope Dart, 'Wingsuit, Second Strike']"], "True", "True"], - }, - "Key of Strength Shop": { - "exits": [ - "Searing Crags - Searing Mega Shard Shop", - ], - "rules": ["True"], - }, - "Triple Ball Spinner Checkpoint": { - "exits": [ - "Searing Crags - Rope Dart Shop", - "Searing Crags - Falling Rocks Shop", - ], - "rules": ["True", "True"], - }, - "Raining Rocks Checkpoint": { - "exits": [ - "Searing Crags - Searing Mega Shard Shop", - "Searing Crags - Before Final Climb Shop", - ], - "rules": ["True", "True"], - }, + "Left": [ + "Quillshroom Marsh - Top Right", + "Searing Crags - Rope Dart Shop", + ], + "Top": [ + "Searing Crags - Colossuses Shop", + "Glacial Peak - Bottom", + ], + "Bottom": [ + "Searing Crags - Portal", + "Quillshroom Marsh - Bottom Right", + ], + "Right": [ + "Searing Crags - Portal", + "Underworld - Left", + ], + "Portal": [ + "Searing Crags - Bottom", + "Searing Crags - Right", + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Colossuses Shop", + "Tower HQ", + ], + "Rope Dart Shop": [ + "Searing Crags - Left", + "Searing Crags - Triple Ball Spinner Checkpoint", + ], + "Falling Rocks Shop": [ + "Searing Crags - Triple Ball Spinner Checkpoint", + "Searing Crags - Searing Mega Shard Shop", + ], + "Searing Mega Shard Shop": [ + "Searing Crags - Falling Rocks Shop", + "Searing Crags - Before Final Climb Shop", + ], + "Before Final Climb Shop": [ + "Searing Crags - Raining Rocks Checkpoint", + "Searing Crags - Portal", + "Searing Crags - Colossuses Shop", + ], + "Colossuses Shop": [ + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Key of Strength Shop", + "Searing Crags - Portal", + "Searing Crags - Top", + ], + "Key of Strength Shop": [ + "Searing Crags - Searing Mega Shard Shop", + ], + "Triple Ball Spinner Checkpoint": [ + "Searing Crags - Rope Dart Shop", + "Searing Crags - Falling Rocks Shop", + ], + "Raining Rocks Checkpoint": [ + "Searing Crags - Searing Mega Shard Shop", + "Searing Crags - Before Final Climb Shop", + ], }, "Glacial Peak": { - "Bottom": { - "exits": [ - "Searing Crags - Top", - "Glacial Peak - Ice Climbers' Shop", - ], - "rules": ["True", "True", "True"], - }, - "Left": { - "exits": [ - "Elemental Skylands", - "Glacial Peak - Projectile Spike Pit Checkpoint", - "Glacial Peak - Glacial Mega Shard Shop", - ], - "rules": ["True", "True", "True"], - }, - "Top": { - "exits": [ - "Glacial Peak - Tower Entrance Shop", - "Cloud Ruins - Left", - "Glacial Peak - Portal", - ], - "rules": ["True", "Ruxxtin's Amulet", "True"], - }, - "Portal": { - "exits": [ - "Glacial Peak - Top", - "Tower HQ", - ], - "rules": [["Wingsuit", "Rope Dart"], "True"], - }, - "Ice Climbers' Shop": { - "exits": [ - "Glacial Peak - Bottom", - "Glacial Peak - Projectile Spike Pit Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Glacial Mega Shard Shop": { - "exits": [ - "Glacial Peak - Left", - "Glacial Peak - Air Swag Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Tower Entrance Shop": { - "exits": [ - "Glacial Peak - Top", - "Glacial Peak - Free Climbing Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Projectile Spike Pit Checkpoint": { - "exits": [ - "Glacial Peak - Ice Climbers' Shop", - "Glacial Peak - Left", - ], - "rules": ["True", "True", "True"], - }, - "Air Swag Checkpoint": { - "exits": [ - "Glacial Peak - Glacial Mega Shard Shop", - "Glacial Peak - Free Climbing Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Free Climbing Checkpoint": { - "exits": [ - "Glacial Peak - Air Swag Checkpoint", - "Glacial Peak - Tower Entrance Shop", - ], - "rules": ["True", "True", "True"], - }, + "Bottom": [ + "Searing Crags - Top", + "Glacial Peak - Ice Climbers' Shop", + ], + "Left": [ + "Elemental Skylands", + "Glacial Peak - Projectile Spike Pit Checkpoint", + "Glacial Peak - Glacial Mega Shard Shop", + ], + "Top": [ + "Glacial Peak - Tower Entrance Shop", + "Cloud Ruins - Left", + "Glacial Peak - Portal", + ], + "Portal": [ + "Glacial Peak - Top", + "Tower HQ", + ], + "Ice Climbers' Shop": [ + "Glacial Peak - Bottom", + "Glacial Peak - Projectile Spike Pit Checkpoint", + ], + "Glacial Mega Shard Shop": [ + "Glacial Peak - Left", + "Glacial Peak - Air Swag Checkpoint", + ], + "Tower Entrance Shop": [ + "Glacial Peak - Top", + "Glacial Peak - Free Climbing Checkpoint", + ], + "Projectile Spike Pit Checkpoint": [ + "Glacial Peak - Ice Climbers' Shop", + "Glacial Peak - Left", + ], + "Air Swag Checkpoint": [ + "Glacial Peak - Glacial Mega Shard Shop", + "Glacial Peak - Free Climbing Checkpoint", + ], + "Free Climbing Checkpoint": [ + "Glacial Peak - Air Swag Checkpoint", + "Glacial Peak - Tower Entrance Shop", + ], }, "Tower of Time": { - "Left": { - "exits": [ - "Tower of Time - Final Chance Shop", - ], - "rules": ["True", "True", "True"], - }, - "Final Chance Shop": { - "exits": [ - "Tower of Time - First Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Arcane Golem Shop": { - "exits": [ - "Tower of Time - Sixth Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "First Checkpoint": { - "exits": [ - "Tower of Time - Second Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Second Checkpoint": { - "exits": [ - "Tower of Time - Third Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Third Checkpoint": { - "exits": [ - "Tower of Time - Fourth Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Fourth Checkpoint": { - "exits": [ - "Tower of Time - Fifth Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Fifth Checkpoint": { - "exits": [ - "Tower of Time - Sixth Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Sixth Checkpoint": { - "exits": [ - "Tower of Time - Arcane Golem Shop", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Tower of Time - Final Chance Shop", + ], + "Final Chance Shop": [ + "Tower of Time - First Checkpoint", + ], + "Arcane Golem Shop": [ + "Tower of Time - Sixth Checkpoint", + ], + "First Checkpoint": [ + "Tower of Time - Second Checkpoint", + ], + "Second Checkpoint": [ + "Tower of Time - Third Checkpoint", + ], + "Third Checkpoint": [ + "Tower of Time - Fourth Checkpoint", + ], + "Fourth Checkpoint": [ + "Tower of Time - Fifth Checkpoint", + ], + "Fifth Checkpoint": [ + "Tower of Time - Sixth Checkpoint", + ], + "Sixth Checkpoint": [ + "Tower of Time - Arcane Golem Shop", + ], }, "Cloud Ruins": { - "Left": { - "exits": [ - "Glacial Peak - Top", - "Cloud Ruins - Cloud Entrance Shop", - ], - "rules": ["True", "True", "True"], - }, - "Cloud Entrance Shop": { - "exits": [ - "Cloud Ruins - Left", - "Cloud Ruins - Spike Float Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Pillar Glide Shop": { - "exits": [ - "Cloud Ruins - Spike Float Checkpoint", - "Cloud Ruins - Ghost Pit Checkpoint", - "Cloud Ruins - Crushers' Descent Shop", - ], - "rules": ["True", "Wingsuit, [Rope Dart, Meditation, Path of Resilience]", "True"], - }, - "Crushers' Descent Shop": { - "exits": [ - "Cloud Ruins - Pillar Glide Shop", - "Cloud Ruins - Toothbrush Alley Checkpoint", - ], - "rules": ["True", "True"], - }, - "Seeing Spikes Shop": { - "exits": [ - "Cloud Ruins - Toothbrush Alley Checkpoint", - "Cloud Ruins - Sliding Spikes Shop", - ], - "rules": ["True", "True", "True"], - }, - "Sliding Spikes Shop": { - "exits": [ - "Cloud Ruins - Seeing Spikes Shop", - "Cloud Ruins - Saw Pit Checkpoint", - ], - "rules": ["True", "True"], - }, - "Final Flight Shop": { - "exits": [ - "Cloud Ruins - Saw Pit Checkpoint", - "Cloud Ruins - Manfred's Shop", - ], - "rules": ["True", "True"], - }, - "Manfred's Shop": { - "exits": [ - "Cloud Ruins - Final Flight Shop", - ], - "rules": ["True"], - }, - "Spike Float Checkpoint": { - "exits": [ - "Cloud Ruins - Cloud Entrance Shop", - "Cloud Ruins - Pillar Glide Shop", - ], - "rules": ["True", "True"], - }, - "Ghost Pit Checkpoint": { - "exits": [ - "Cloud Ruins - Pillar Glide Shop", - ], - "rules": ["True"], - }, - "Toothbrush Alley Checkpoint": { - "exits": [ - "Cloud Ruins - Crushers' Descent Shop", - "Cloud Ruins - Seeing Spikes Shop", - ], - "rules": ["True", "True", "True"], - }, - "Saw Pit Checkpoint": { - "exits": [ - "Cloud Ruins - Sliding Spikes Shop", - "Cloud Ruins - Final Flight Shop", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Glacial Peak - Top", + "Cloud Ruins - Cloud Entrance Shop", + ], + "Cloud Entrance Shop": [ + "Cloud Ruins - Left", + "Cloud Ruins - Spike Float Checkpoint", + ], + "Pillar Glide Shop": [ + "Cloud Ruins - Spike Float Checkpoint", + "Cloud Ruins - Ghost Pit Checkpoint", + "Cloud Ruins - Crushers' Descent Shop", + ], + "Crushers' Descent Shop": [ + "Cloud Ruins - Pillar Glide Shop", + "Cloud Ruins - Toothbrush Alley Checkpoint", + ], + "Seeing Spikes Shop": [ + "Cloud Ruins - Toothbrush Alley Checkpoint", + "Cloud Ruins - Sliding Spikes Shop", + ], + "Sliding Spikes Shop": [ + "Cloud Ruins - Seeing Spikes Shop", + "Cloud Ruins - Saw Pit Checkpoint", + ], + "Final Flight Shop": [ + "Cloud Ruins - Saw Pit Checkpoint", + "Cloud Ruins - Manfred's Shop", + ], + "Manfred's Shop": [ + "Cloud Ruins - Final Flight Shop", + ], + "Spike Float Checkpoint": [ + "Cloud Ruins - Cloud Entrance Shop", + "Cloud Ruins - Pillar Glide Shop", + ], + "Ghost Pit Checkpoint": [ + "Cloud Ruins - Pillar Glide Shop", + ], + "Toothbrush Alley Checkpoint": [ + "Cloud Ruins - Crushers' Descent Shop", + "Cloud Ruins - Seeing Spikes Shop", + ], + "Saw Pit Checkpoint": [ + "Cloud Ruins - Sliding Spikes Shop", + "Cloud Ruins - Final Flight Shop", + ], }, "Underworld": { - "Left": { - "exits": [ - "Underworld - Left Shop", - "Searing Crags - Right", - ], - "rules": ["True", "True", "True"], - }, - "Left Shop": { - "exits": [ - "Underworld - Left", - "Underworld - Hot Dip Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Fireball Wave Shop": { - "exits": [ - "Underworld - Hot Dip Checkpoint", - "Underworld - Long Climb Shop", - ], - "rules": ["True", "True", "True"], - }, - "Long Climb Shop": { - "exits": [ - "Underworld - Fireball Wave Shop", - "Underworld - Hot Tub Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Barm'athaziel Shop": { - "exits": [ - "Underworld - Hot Tub Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Key of Chaos Shop": { - "exits": [ - ], - "rules": ["True", "True", "True"], - }, - "Hot Dip Checkpoint": { - "exits": [ - "Underworld - Left Shop", - "Underworld - Fireball Wave Shop", - "Underworld - Lava Run Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Hot Tub Checkpoint": { - "exits": [ - "Underworld - Long Climb Shop", - "Underworld - Barm'athaziel Shop", - ], - "rules": ["True", "True", "True"], - }, - "Lava Run Checkpoint": { - "exits": [ - "Underworld - Hot Dip Checkpoint", - "Underworld - Key of Chaos Shop", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Underworld - Left Shop", + "Searing Crags - Right", + ], + "Left Shop": [ + "Underworld - Left", + "Underworld - Hot Dip Checkpoint", + ], + "Fireball Wave Shop": [ + "Underworld - Hot Dip Checkpoint", + "Underworld - Long Climb Shop", + ], + "Long Climb Shop": [ + "Underworld - Fireball Wave Shop", + "Underworld - Hot Tub Checkpoint", + ], + "Barm'athaziel Shop": [ + "Underworld - Hot Tub Checkpoint", + ], + "Key of Chaos Shop": [ + ], + "Hot Dip Checkpoint": [ + "Underworld - Left Shop", + "Underworld - Fireball Wave Shop", + "Underworld - Lava Run Checkpoint", + ], + "Hot Tub Checkpoint": [ + "Underworld - Long Climb Shop", + "Underworld - Barm'athaziel Shop", + ], + "Lava Run Checkpoint": [ + "Underworld - Hot Dip Checkpoint", + "Underworld - Key of Chaos Shop", + ], }, "Dark Cave": { - "Right": { - "exits": [ - "Catacombs - Bottom", - "Dark Cave - Left", - ], - "rules": ["True", "True", "True"], - }, - "Left": { - "exits": [ - "Riviere Turquoise - Right", - ], - "rules": ["True", "True", "True"], - }, + "Right": [ + "Catacombs - Bottom", + "Dark Cave - Left", + ], + "Left": [ + "Riviere Turquoise - Right", + ], }, "Riviere Turquoise": { - "Right": { - "exits": [ - "Riviere Turquoise - Portal", - ], - "rules": ["True", "True", "True"], - }, - "Portal": { - "exits": [ - "Riviere Turquoise - Waterfall Shop", - "Tower HQ", - ], - "rules": ["True", "True", "True"], - }, - "Waterfall Shop": { - "exits": [ - "Riviere Turquoise - Portal", - "Riviere Turquoise - Flower Flight Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Launch of Faith Shop": { - "exits": [ - "Riviere Turquoise - Flower Flight Checkpoint", - "Riviere Turquoise - Log Flume Shop", - ], - "rules": ["True", "True", "True"], - }, - "Log Flume Shop": { - "exits": [ - "Riviere Turquoise - Log Climb Shop", - ], - "rules": ["True", "True", "True"], - }, - "Log Climb Shop": { - "exits": [ - "Riviere Turquoise - Restock Shop", - ], - "rules": ["True", "True", "True"], - }, - "Restock Shop": { - "exits": [ - "Riviere Turquoise - Butterfly Matriarch Shop", - ], - "rules": ["True", "True", "True"], - }, - "Butterfly Matriarch Shop": { - "exits": [ - ], - "rules": ["True", "True", "True"], - }, - "Flower Flight Checkpoint": { - "exits": [ - "Riviere Turquoise - Waterfall Shop", - "Riviere Turquoise - Launch of Faith Shop", - ], - "rules": ["True", "True", "True"], - }, + "Right": [ + "Riviere Turquoise - Portal", + ], + "Portal": [ + "Riviere Turquoise - Waterfall Shop", + "Tower HQ", + ], + "Waterfall Shop": [ + "Riviere Turquoise - Portal", + "Riviere Turquoise - Flower Flight Checkpoint", + ], + "Launch of Faith Shop": [ + "Riviere Turquoise - Flower Flight Checkpoint", + "Riviere Turquoise - Log Flume Shop", + ], + "Log Flume Shop": [ + "Riviere Turquoise - Log Climb Shop", + ], + "Log Climb Shop": [ + "Riviere Turquoise - Restock Shop", + ], + "Restock Shop": [ + "Riviere Turquoise - Butterfly Matriarch Shop", + ], + "Butterfly Matriarch Shop": [ + ], + "Flower Flight Checkpoint": [ + "Riviere Turquoise - Waterfall Shop", + "Riviere Turquoise - Launch of Faith Shop", + ], }, "Sunken Shrine": { - "Left": { - "exits": [ - "Howling Grotto - Bottom", - "Sunken Shrine - Portal", - ], - "rules": ["True", "True", "True"], - }, - "Portal": { - "exits": [ - "Sunken Shrine - Left", - "Sunken Shrine - Above Portal Shop", - "Sunken Shrine - Sun Path Shop", - "Sunken Shrine - Moon Path Shop", - "Tower HQ", - ], - "rules": ["True", "True", "Lightfoot Tabi", "Lightfoot Tabi", "True"], - }, - "Above Portal Shop": { - "exits": [ - "Sunken Shrine - Portal", - "Sunken Shrine - Lifeguard Shop", - ], - "rules": ["True", "True"], - }, - "Lifeguard Shop": { - "exits": [ - "Sunken Shrine - Above Portal Shop", - "Sunken Shrine - Lightfoot Tabi Checkpoint", - ], - "rules": ["True", "True"], - }, - "Sun Path Shop": { - "exits": [ - "Sunken Shrine - Portal", - "Sunken Shrine - Tabi Gauntlet Shop", - ], - "rules": ["True", "True", "True"], - }, - "Tabi Gauntlet Shop": { - "exits": [ - "Sunken Shrine - Sun Path Shop", - "Sunken Shrine - Sun Crest Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Moon Path Shop": { - "exits": [ - "Sunken Shrine - Portal", - "Sunken Shrine - Waterfall Paradise Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Lightfoot Tabi Checkpoint": { - "exits": [ - "Sunken Shrine - Portal", - ], - "rules": ["True", "True", "True"], - }, - "Sun Crest Checkpoint": { - "exits": [ - "Sunken Shrine - Tabi Gauntlet Shop", - "Sunken Shrine - Portal", - ], - "rules": ["True", "True", "True"], - }, - "Waterfall Paradise Checkpoint": { - "exits": [ - "Sunken Shrine - Moon Path Shop", - "Sunken Shrine - Moon Crest Checkpoint", - ], - "rules": ["True", "True", "True"], - }, - "Moon Crest Checkpoint": { - "exits": [ - "Sunken Shrine - Waterfall Paradise Checkpoint", - "Sunken Shrine - Portal", - ], - "rules": ["True", "True", "True"], - }, + "Left": [ + "Howling Grotto - Bottom", + "Sunken Shrine - Portal", + ], + "Portal": [ + "Sunken Shrine - Left", + "Sunken Shrine - Above Portal Shop", + "Sunken Shrine - Sun Path Shop", + "Sunken Shrine - Moon Path Shop", + "Tower HQ", + ], + "Above Portal Shop": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Lifeguard Shop", + ], + "Lifeguard Shop": [ + "Sunken Shrine - Above Portal Shop", + "Sunken Shrine - Lightfoot Tabi Checkpoint", + ], + "Sun Path Shop": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Tabi Gauntlet Shop", + ], + "Tabi Gauntlet Shop": [ + "Sunken Shrine - Sun Path Shop", + "Sunken Shrine - Sun Crest Checkpoint", + ], + "Moon Path Shop": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Waterfall Paradise Checkpoint", + ], + "Lightfoot Tabi Checkpoint": [ + "Sunken Shrine - Portal", + ], + "Sun Crest Checkpoint": [ + "Sunken Shrine - Tabi Gauntlet Shop", + "Sunken Shrine - Portal", + ], + "Waterfall Paradise Checkpoint": [ + "Sunken Shrine - Moon Path Shop", + "Sunken Shrine - Moon Crest Checkpoint", + ], + "Moon Crest Checkpoint": [ + "Sunken Shrine - Waterfall Paradise Checkpoint", + "Sunken Shrine - Portal", + ], }, } From 81d247294004e12b57d4707135fdb89361b57a96 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 30 Jan 2024 21:16:04 -0600 Subject: [PATCH 123/163] set topology_present to true when portals are shuffled --- worlds/messenger/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index bf971d6fe268..ab910157af4d 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -198,6 +198,7 @@ def set_rules(self) -> None: add_closed_portal_reqs(self) # i need ER to happen after rules exist so i can validate it if self.options.shuffle_portals: + self.__class__.topology_present = True disconnect_portals(self) shuffle_portals(self) @@ -224,7 +225,7 @@ def generate_output(self, output_directory: str) -> None: dump(data, f) def fill_slot_data(self) -> Dict[str, Any]: - visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) + # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, From 33b74e510fe258dc301a279556f4ee5b5f76a504 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Tue, 30 Jan 2024 21:19:08 -0600 Subject: [PATCH 124/163] add requirement for ghost pit loc since it's pretty hard without movement --- worlds/messenger/rules.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index a22593579f93..2dfe147902c0 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -237,6 +237,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Tower of Time Seal - Time Waster": self.has_dart, # cloud ruins "Time Warp Mega Shard": lambda state: self.has_vertical(state) or self.can_dboost(state), + "Cloud Ruins Seal - Ghost Pit": self.has_vertical, # nothing needed for hard "Cloud Ruins Seal - Toothbrush Alley": self.has_dart, # nothing needed for hard "Cloud Ruins Seal - Saw Pit": self.has_vertical, # nothing for hard # underworld From 88ea78ad3f951ee5eea08ac14d1618f6c66e4cc8 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 3 Feb 2024 23:01:13 -0600 Subject: [PATCH 125/163] bring hard logic back --- worlds/messenger/__init__.py | 18 +- worlds/messenger/client_setup.py | 16 +- worlds/messenger/connections.py | 6 +- worlds/messenger/rules.py | 318 +++++++++++++++++--------- worlds/messenger/test/test_logic.py | 14 +- worlds/messenger/test/test_portals.py | 21 +- 6 files changed, 251 insertions(+), 142 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index ab910157af4d..e589320fd189 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -111,6 +111,8 @@ def generate_early(self) -> None: self.starting_portals = [f"{portal} Portal" for portal in PORTALS[:3] + self.random.sample(PORTALS[3:], k=self.options.available_portals - 3)] + self.portal_mapping = [] + self.spoiler_portal_mapping = {} def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld @@ -123,7 +125,7 @@ def create_regions(self) -> None: for reg_name in sub_region]: region_name = region.name.replace(f"{region.parent} - ", "") connection_data = CONNECTIONS[region.parent][region_name] - for exit_region in connection_data["exits"]: + for exit_region in connection_data: region.connect(self.multiworld.get_region(exit_region, self.player)) # all regions need to be created before i can do these connections so we create and connect the complex first for region_name in [level for level in LEVELS if level in REGION_CONNECTIONS]: @@ -187,12 +189,12 @@ def create_items(self) -> None: self.multiworld.itempool += filler def set_rules(self) -> None: - MessengerRules(self).set_messenger_rules() - # logic = self.options.logic_level - # if logic == Logic.option_normal: - # MessengerRules(self).set_messenger_rules() - # elif logic == Logic.option_hard: - # MessengerHardRules(self).set_messenger_rules() + # MessengerRules(self).set_messenger_rules() + logic = self.options.logic_level + if logic == Logic.option_normal: + MessengerRules(self).set_messenger_rules() + else: + MessengerHardRules(self).set_messenger_rules() # else: # MessengerOOBRules(self).set_messenger_rules() add_closed_portal_reqs(self) @@ -232,7 +234,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "max_price": self.total_shards, "required_seals": self.required_seals, "starting_portals": self.starting_portals, - "portal_exits": self.portal_mapping if self.portal_mapping else [], + "portal_exits": getattr(self, "portal_mapping", []), **self.options.as_dict("music_box", "death_link", "logic_level"), } return slot_data diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 91b4ea937da7..4e6947dcbc3f 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -2,13 +2,13 @@ import logging import os.path import subprocess -import sys import urllib.request from pathlib import Path from shutil import which from tkinter.messagebox import askyesnocancel from typing import Any, Optional from zipfile import ZipFile +from Utils import open_file import requests @@ -81,14 +81,15 @@ def install_courier() -> None: if courier_installed(): messagebox("Success!", "Courier successfully installed!") + return messagebox("Failure", "Failed to install Courier", True) raise RuntimeError("Failed to install Courier") def install_mod() -> None: """Installs latest version of the mod""" # TODO: add /latest before actual PR since i want pre-releases for now - url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" - assets = request_data(url)["assets"] + get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" + assets = request_data(get_url)[0]["assets"] for asset in assets: if "TheMessengerRandomizerModAP" in asset["name"]: release_url = asset["browser_download_url"] @@ -106,8 +107,8 @@ def install_mod() -> None: def available_mod_update() -> bool: """Check if there's an available update""" - url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" - assets = request_data(url)[0]["assets"] + get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" + assets = request_data(get_url)[0]["assets"] # TODO simplify once we're done with 0.13.0 alpha for asset in assets: if "TheMessengerRandomizerAP" in asset["name"]: @@ -162,7 +163,10 @@ def available_mod_update() -> bool: install_mod() logging.info(url) if is_linux: - os.startfile("steam://rungameid/764790") + if url: + open_file(f"steam://rungameid/764790//{url}/") + else: + open_file("steam://rungameid/764790") else: os.chdir(Path(MessengerWorld.settings.game_path).parent) if url: diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index a333c55d4120..485c2285215e 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -31,6 +31,7 @@ "Autumn Hills - Left", "Autumn Hills - Hope Path Shop", "Autumn Hills - Lakeside Checkpoint", + "Autumn Hills - Key of Hope Checkpoint", ], "Hope Path Shop": [ "Autumn Hills - Climbing Claws Shop", @@ -320,6 +321,7 @@ "Searing Mega Shard Shop": [ "Searing Crags - Falling Rocks Shop", "Searing Crags - Before Final Climb Shop", + "Searing Crags - Key of Strength Shop", ], "Before Final Climb Shop": [ "Searing Crags - Raining Rocks Checkpoint", @@ -357,10 +359,9 @@ "Top": [ "Glacial Peak - Tower Entrance Shop", "Cloud Ruins - Left", - "Glacial Peak - Portal", ], "Portal": [ - "Glacial Peak - Top", + "Glacial Peak - Tower Entrance Shop", "Tower HQ", ], "Ice Climbers' Shop": [ @@ -374,6 +375,7 @@ "Tower Entrance Shop": [ "Glacial Peak - Top", "Glacial Peak - Free Climbing Checkpoint", + "Glacial Peak - Portal", ], "Projectile Spike Pit Checkpoint": [ "Glacial Peak - Ice Climbers' Shop", diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 2dfe147902c0..ce6e489fd24a 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -45,6 +45,8 @@ def __init__(self, world: "MessengerWorld") -> None: self.has_vertical, "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Hope Path Shop": self.has_dart, + "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Key of Hope Checkpoint": + self.false, # hard logic only "Autumn Hills - Hope Path Shop -> Autumn Hills - Hope Latch Checkpoint": self.has_dart, "Autumn Hills - Hope Path Shop -> Autumn Hills - Climbing Claws Shop": @@ -84,8 +86,12 @@ def __init__(self, world: "MessengerWorld") -> None: "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop": self.has_wingsuit, "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Crushing Pits Shop": - lambda state: (self.has_wingsuit(state) or self.can_dboost(state) or self.can_destroy_projectiles(state)) - and state.multiworld.get_region("Howling Grotto - Emerald Golem Shop", self.player).can_reach(state), + lambda state: (self.has_wingsuit(state) or self.can_dboost( + state + ) or self.can_destroy_projectiles(state)) + and state.multiworld.get_region( + "Howling Grotto - Emerald Golem Shop", self.player + ).can_reach(state), "Howling Grotto - Emerald Golem Shop -> Howling Grotto - Right": self.has_wingsuit, # Searing Crags @@ -107,22 +113,25 @@ def __init__(self, world: "MessengerWorld") -> None: or (self.has_wingsuit(state) and self.can_destroy_projectiles(state))), "Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop": - self.has_dart, # or strike for hard + self.has_dart, "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Before Final Climb Shop": lambda state: self.has_dart(state) or self.can_destroy_projectiles(state), "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop": self.has_dart, + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Key of Strength Shop": + self.false, "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": - self.has_dart, # doable itemless for hard, but only 16-bit + self.has_dart, # Glacial Peak - "Glacial Peak - Portal -> Glacial Peak - Top": + "Glacial Peak - Portal -> Glacial Peak - Tower Entrance Shop": self.has_vertical, "Glacial Peak - Left -> Elemental Skylands": # if we can ever shuffle elemental skylands stuff around wingsuit isn't needed here. lambda state: state.has("Magic Firefly", self.player) - and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player).can_reach(state) + and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player) + .can_reach(state) and self.has_wingsuit(state), - "Glacial Peak - Top -> Cloud Ruins - Left": - lambda state: self.has_vertical(state) and state.has("Ruxxtin's Amulet", self.player), + "Glacial Peak - Tower Entrance Shop -> Glacial Peak - Top": + lambda state: state.has("Ruxxtin's Amulet", self.player), "Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left": lambda state: self.has_dart(state) or (self.can_dboost(state) and self.has_wingsuit(state)), # Tower of Time @@ -156,7 +165,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Seeing Spikes Shop": self.has_wingsuit, "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint": - self.has_vertical, # nothing needed for hard + self.has_vertical, "Cloud Ruins - Final Flight Shop -> Cloud Ruins - Manfred's Shop": lambda state: self.has_wingsuit(state) and self.has_dart(state), "Cloud Ruins - Manfred's Shop -> Cloud Ruins - Final Flight Shop": @@ -187,11 +196,12 @@ def __init__(self, world: "MessengerWorld") -> None: lambda state: state.has("Candle", self.player) and self.has_dart(state), # Riviere Turquoise "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": - lambda state: self.has_dart(state) or (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), + lambda state: self.has_dart(state) or ( + self.has_wingsuit(state) and self.can_destroy_projectiles(state)), "Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint": - lambda state: self.has_dart(state) and self.can_dboost(state), # doable with only d-boosting but it's pretty hard so only for hard logic + lambda state: self.has_dart(state) and self.can_dboost(state), "Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop": - lambda state: False, # doable with d-boosting but it's pretty hard so only for hard logic + lambda state: False, # Sunken Shrine "Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop": self.has_tabi, @@ -204,63 +214,96 @@ def __init__(self, world: "MessengerWorld") -> None: "Sunken Shrine - Tabi Gauntlet Shop -> Sunken Shrine - Sun Path Shop": lambda state: self.can_dboost(state) or self.has_dart(state), } - + self.location_rules = { # ninja village - "Ninja Village Seal - Tree House": self.has_dart, + "Ninja Village Seal - Tree House": + self.has_dart, "Ninja Village - Candle": - lambda state: state.multiworld.get_location("Searing Crags - Astral Tea Leaves", self.player).can_reach(state), + lambda state: state.multiworld.get_location("Searing Crags - Astral Tea Leaves", self.player).can_reach( + state), # autumn hills - "Autumn Hills Seal - Spike Ball Darts": self.is_aerobatic, - "Autumn Hills Seal - Trip Saws": self.has_wingsuit, + "Autumn Hills Seal - Spike Ball Darts": + self.is_aerobatic, + "Autumn Hills Seal - Trip Saws": + self.has_wingsuit, # forlorn temple - "Forlorn Temple Seal - Rocket Maze": self.has_vertical, + "Forlorn Temple Seal - Rocket Maze": + self.has_vertical, # bamboo creek - "Bamboo Creek - Claustro": lambda state: self.has_wingsuit(state) and - (self.has_dart(state) or self.can_dboost(state)), - "Above Entrance Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state), - "Bamboo Creek Seal - Spike Ball Pits": self.has_wingsuit, + "Bamboo Creek - Claustro": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Above Entrance Mega Shard": + lambda state: self.has_dart(state) or self.can_dboost(state), + "Bamboo Creek Seal - Spike Ball Pits": + self.has_wingsuit, # howling grotto - "Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit, - "Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state), - "Howling Grotto - Emerald Golem": self.has_wingsuit, + "Howling Grotto Seal - Windy Saws and Balls": + self.has_wingsuit, + "Howling Grotto Seal - Crushing Pits": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Howling Grotto - Emerald Golem": + self.has_wingsuit, # searing crags "Searing Crags - Astral Tea Leaves": lambda state: state.multiworld.get_location("Ninja Village - Astral Seed", self.player).can_reach(state), - "Searing Crags Seal - Triple Ball Spinner": self.can_dboost, + "Searing Crags Seal - Triple Ball Spinner": + self.can_dboost, "Searing Crags - Pyro": self.has_tabi, # glacial peak - "Glacial Peak Seal - Ice Climbers": self.has_dart, - "Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles, + "Glacial Peak Seal - Ice Climbers": + self.has_dart, + "Glacial Peak Seal - Projectile Spike Pit": + self.can_destroy_projectiles, # tower of time - "Tower of Time Seal - Time Waster": self.has_dart, + "Tower of Time Seal - Time Waster": + self.has_dart, # cloud ruins - "Time Warp Mega Shard": lambda state: self.has_vertical(state) or self.can_dboost(state), - "Cloud Ruins Seal - Ghost Pit": self.has_vertical, # nothing needed for hard - "Cloud Ruins Seal - Toothbrush Alley": self.has_dart, # nothing needed for hard - "Cloud Ruins Seal - Saw Pit": self.has_vertical, # nothing for hard + "Time Warp Mega Shard": + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Cloud Ruins Seal - Ghost Pit": + self.has_vertical, + "Cloud Ruins Seal - Toothbrush Alley": + self.has_dart, + "Cloud Ruins Seal - Saw Pit": + self.has_vertical, # underworld - "Underworld Seal - Sharp and Windy Climb": self.has_wingsuit, - "Underworld Seal - Fireball Wave": self.is_aerobatic, - "Underworld Seal - Rising Fanta": self.has_dart, - "Hot Tub Mega Shard": lambda state: self.has_tabi(state) or self.has_dart(state), + "Underworld Seal - Sharp and Windy Climb": + self.has_wingsuit, + "Underworld Seal - Fireball Wave": + self.is_aerobatic, + "Underworld Seal - Rising Fanta": + self.has_dart, + "Hot Tub Mega Shard": + lambda state: self.has_tabi(state) or self.has_dart(state), # sunken shrine - "Sunken Shrine - Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), - "Sunken Shrine Seal - Waterfall Paradise": self.has_tabi, - "Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi, - "Mega Shard of the Sun": self.has_tabi, + "Sunken Shrine - Key of Love": + lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), + "Sunken Shrine Seal - Waterfall Paradise": + self.has_tabi, + "Sunken Shrine Seal - Tabi Gauntlet": + self.has_tabi, + "Mega Shard of the Sun": + self.has_tabi, # riviere turquoise - "Riviere Turquoise Seal - Bounces and Balls": self.can_dboost, - "Riviere Turquoise Seal - Launch of Faith": lambda state: self.can_dboost(state) or self.has_dart(state), + "Riviere Turquoise Seal - Bounces and Balls": + self.can_dboost, + "Riviere Turquoise Seal - Launch of Faith": + lambda state: self.has_vertical(state), # elemental skylands - "Elemental Skylands - Key of Symbiosis": self.has_dart, - "Elemental Skylands Seal - Air": self.has_wingsuit, - "Elemental Skylands Seal - Water": lambda state: self.has_dart(state) and - state.has("Currents Master", self.player), - "Elemental Skylands Seal - Fire": lambda state: self.has_dart(state) and self.can_destroy_projectiles(state), - "Earth Mega Shard": self.has_dart, - "Water Mega Shard": self.has_dart, + "Elemental Skylands - Key of Symbiosis": + self.has_dart, + "Elemental Skylands Seal - Air": + self.has_wingsuit, + "Elemental Skylands Seal - Water": + lambda state: self.has_dart(state) and state.has("Currents Master", self.player), + "Elemental Skylands Seal - Fire": + lambda state: self.has_dart(state) and self.can_destroy_projectiles(state), + "Earth Mega Shard": + self.has_dart, + "Water Mega Shard": + self.has_dart, } def has_wingsuit(self, state: CollectionState) -> bool: @@ -284,7 +327,7 @@ def can_destroy_projectiles(self, state: CollectionState) -> bool: def can_dboost(self, state: CollectionState) -> bool: return state.has_any({"Path of Resilience", "Meditation"}, self.player) and \ state.has("Second Wind", self.player) - + def can_double_dboost(self, state: CollectionState) -> bool: return state.has_all({"Path of Resilience", "Meditation", "Second Wind"}, self.player) @@ -295,6 +338,10 @@ def true(self, state: CollectionState) -> bool: """I know this is stupid, but it's easier to read in the dicts.""" return True + def false(self, state: CollectionState) -> bool: + """It's a bit easier to just always create the connections that are only possible in hard or higher logic.""" + return False + def can_shop(self, state: CollectionState) -> bool: return state.has("Shards", self.player, self.maximum_price) @@ -316,68 +363,123 @@ def set_messenger_rules(self) -> None: class MessengerHardRules(MessengerRules): - extra_rules: Dict[str, CollectionRule] - def __init__(self, world: "MessengerWorld") -> None: super().__init__(world) - self.region_rules.update({ - "Ninja Village": self.has_vertical, - "Autumn Hills": self.has_vertical, - "Catacombs": self.has_vertical, - "Bamboo Creek": self.has_vertical, - "Riviere Turquoise": self.true, - "Forlorn Temple": lambda state: self.has_vertical(state) and state.has_all(PHOBEKINS, self.player), - "Searing Crags Upper": lambda state: self.can_destroy_projectiles(state) or self.has_windmill(state) - or self.has_vertical(state), - "Glacial Peak": lambda state: self.can_destroy_projectiles(state) or self.has_windmill(state) - or self.has_vertical(state), - "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) or - self.has_windmill(state) or - self.has_dart(state), - }) - - self.location_rules.update({ - "Howling Grotto Seal - Windy Saws and Balls": self.true, - "Searing Crags Seal - Triple Ball Spinner": self.true, - "Searing Crags Seal - Raining Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Searing Crags Seal - Rhythm Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Searing Crags - Power Thistle": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Glacial Peak Seal - Ice Climbers": lambda state: self.has_vertical(state) or self.can_dboost(state), - "Glacial Peak Seal - Projectile Spike Pit": self.true, - "Glacial Peak Seal - Glacial Air Swag": lambda state: self.has_windmill(state) or self.has_vertical(state), - "Glacial Peak Mega Shard": lambda state: self.has_windmill(state) or self.has_vertical(state), - "Cloud Ruins Seal - Ghost Pit": self.true, - "Bamboo Creek - Claustro": self.has_wingsuit, - "Tower of Time Seal - Lantern Climb": self.has_wingsuit, - "Elemental Skylands Seal - Water": lambda state: self.has_dart(state) or self.can_dboost(state) - or self.has_windmill(state), - "Elemental Skylands Seal - Fire": lambda state: (self.has_dart(state) or self.can_dboost(state) - or self.has_windmill(state)) and - self.can_destroy_projectiles(state), - "Earth Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), - "Water Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), - }) - - self.extra_rules = { - "Searing Crags - Key of Strength": lambda state: self.has_dart(state) or self.has_windmill(state), - "Elemental Skylands - Key of Symbiosis": lambda state: self.has_windmill(state) or self.can_dboost(state), - "Autumn Hills Seal - Spike Ball Darts": lambda state: self.has_dart(state) or self.has_windmill(state), - "Underworld Seal - Fireball Wave": self.has_windmill, - } + self.connection_rules.update( + { + # Autumn Hills + "Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop": + self.has_dart, + "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Key of Hope Checkpoint": + self.true, # super easy normal clip - also possible with moderately difficult cloud stepping + # Howling Grotto + "Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop": + self.true, + "Howling Grotto - Lost Woods Checkpoint -> Howling Grotto - Bottom": + self.true, # just memorize the pattern :) + "Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal": + self.true, + "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop": + lambda state: self.has_wingsuit(state) or # there's a very easy normal clip here but it's 16-bit only + "Howling Grotto - Breezy Crushers Checkpoint" in self.world.spoiler_portal_mapping.values(), + # Searing Crags + "Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint": + lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), + # it's doable without anything but one jump is pretty hard and time warping is no longer reliable + "Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop": + lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop": + lambda state: self.has_dart(state) or + (self.can_destroy_projectiles(state) and + (self.has_wingsuit(state) or self.can_dboost(state))), + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Key of Strength Shop": + lambda state: self.can_leash(state) or self.has_windmill(state), + "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": + self.true, + # Glacial Peak + "Glacial Peak - Left -> Elemental Skylands": + lambda state: self.has_windmill(state) or + (state.has("Magic Firefly", self.player) and + state.multiworld.get_location( + "Quillshroom Marsh - Queen of Quills", self.player).can_reach(state)) or + (self.has_dart(state) and self.can_dboost(state)), + "Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left": + lambda state: self.has_vertical(state) or self.has_windmill(state), + # Cloud Ruins + "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint": + self.true, + # Riviere Turquoise + "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": + self.true, + "Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint": + self.can_dboost, + "Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop": + self.can_double_dboost, + } + ) + + self.location_rules.update( + { + "Autumn Hills Seal - Spike Ball Darts": + lambda state: self.has_vertical(state) and self.has_windmill(state) or self.is_aerobatic(state), + "Bamboo Creek - Claustro": + self.has_wingsuit, + "Bamboo Creek Seal - Spike Ball Pits": + self.true, + "Howling Grotto Seal - Windy Saws and Balls": + self.true, + "Searing Crags Seal - Triple Ball Spinner": + self.true, + "Glacial Peak Seal - Ice Climbers": + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Glacial Peak Seal - Projectile Spike Pit": + lambda state: self.can_dboost(state) or self.can_destroy_projectiles(state), + "Glacial Peak Seal - Glacial Air Swag": + lambda state: self.has_windmill(state) or self.has_vertical(state), + "Glacial Peak Mega Shard": + lambda state: self.has_windmill(state) or self.has_vertical(state), + "Cloud Ruins Seal - Ghost Pit": + self.true, + "Cloud Ruins Seal - Toothbrush Alley": + self.true, + "Cloud Ruins Seal - Saw Pit": + self.true, + "Underworld Seal - Fireball Wave": + lambda state: self.is_aerobatic(state) or self.has_windmill(state), + "Riviere Turquoise Seal - Bounces and Balls": + self.true, + "Riviere Turquoise Seal - Launch of Faith": + lambda state: self.can_dboost(state) or self.has_vertical(state), + "Elemental Skylands - Key of Symbiosis": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Elemental Skylands Seal - Water": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Elemental Skylands Seal - Fire": + lambda state: (self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state)) + and self.can_destroy_projectiles(state), + "Earth Mega Shard": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Water Mega Shard": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + } + ) def has_windmill(self, state: CollectionState) -> bool: return state.has("Windmill Shuriken", self.player) - def set_messenger_rules(self) -> None: - super().set_messenger_rules() - for loc, rule in self.extra_rules.items(): - if not self.world.options.shuffle_shards and "Shard" in loc: - continue - add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or") + def can_dboost(self, state: CollectionState) -> bool: + return state.has("Second Wind", self.player) # who really needs meditation + + def can_destroy_projectiles(self, state: CollectionState) -> bool: + return super().can_destroy_projectiles(state) or self.has_windmill(state) + + def can_leash(self, state: CollectionState) -> bool: + return self.has_dart(state) and self.can_dboost(state) class MessengerOOBRules(MessengerRules): + def __init__(self, world: "MessengerWorld") -> None: self.world = world self.player = world.player @@ -385,7 +487,9 @@ def __init__(self, world: "MessengerWorld") -> None: self.required_seals = max(1, world.required_seals) self.region_rules = { "Elemental Skylands": - lambda state: state.has_any({"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player), + lambda state: state.has_any( + {"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player + ), "Music Box": lambda state: state.has_all(set(NOTES), self.player) or self.has_enough_seals(state), } @@ -399,8 +503,10 @@ def __init__(self, world: "MessengerWorld") -> None: lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player), "Autumn Hills Seal - Spike Ball Darts": self.has_dart, "Ninja Village Seal - Tree House": self.has_dart, - "Underworld Seal - Fireball Wave": lambda state: state.has_any({"Wingsuit", "Windmill Shuriken"}, - self.player), + "Underworld Seal - Fireball Wave": lambda state: state.has_any( + {"Wingsuit", "Windmill Shuriken"}, + self.player + ), "Tower of Time Seal - Time Waster": self.has_dart, } diff --git a/worlds/messenger/test/test_logic.py b/worlds/messenger/test/test_logic.py index 15df89b92097..c13bd5c5a008 100644 --- a/worlds/messenger/test/test_logic.py +++ b/worlds/messenger/test/test_logic.py @@ -41,7 +41,7 @@ def test_vertical(self) -> None: # cloud ruins "Cloud Ruins - Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", - "Cloud Entrance Mega Shard", "Time Warp Mega Shard", "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2", + "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2", # underworld "Underworld Seal - Rising Fanta", "Underworld Seal - Sharp and Windy Climb", # elemental skylands @@ -80,18 +80,6 @@ def test_windmill(self) -> None: self.collect(item) self.assertTrue(self.can_reach_location(special_loc)) - def test_glacial(self) -> None: - """Test Glacial Peak locations.""" - self.assertAccessDependency(["Glacial Peak Seal - Ice Climbers"], - [["Second Wind", "Meditation"], ["Rope Dart"], ["Wingsuit"]], - True) - self.assertAccessDependency(["Glacial Peak Seal - Projectile Spike Pit"], - [["Strike of the Ninja"], ["Windmill Shuriken"], ["Rope Dart"], ["Wingsuit"]], - True) - self.assertAccessDependency(["Glacial Peak Seal - Glacial Air Swag", "Glacial Peak Mega Shard"], - [["Windmill Shuriken"], ["Wingsuit"], ["Rope Dart"]], - True) - class NoLogicTest(MessengerTestBase): options = { diff --git a/worlds/messenger/test/test_portals.py b/worlds/messenger/test/test_portals.py index 1b1c7b3eb1b0..1715c388e7eb 100644 --- a/worlds/messenger/test/test_portals.py +++ b/worlds/messenger/test/test_portals.py @@ -9,13 +9,14 @@ def test_portal_reqs(self) -> None: """tests the paths to open a portal if only that portal is closed with vanilla connections.""" # portal and requirements to reach it if it's the only closed portal portal_requirements = { - "Autumn Hills Portal": [["Autumn Hills Portal", "Wingsuit"]], # grotto -> bamboo -> catacombs -> hills - "Riviere Turquoise Portal": [["Riviere Turquoise Portal", "Candle", "Wingsuit", "Rope Dart"]], # hills -> catacombs -> dark cave -> riviere - "Howling Grotto Portal": [["Howling Grotto Portal", "Wingsuit"], ["Howling Grotto Portal", "Meditation", "Second Wind"]], # crags -> quillshroom -> grotto - "Sunken Shrine Portal": [["Sunken Shrine Portal", "Seashell"]], # crags -> quillshroom -> grotto -> shrine - "Searing Crags Portal": [["Searing Crags Portal", "Wingsuit"], ["Searing Crags Portal", "Rope Dart"]], # grotto -> quillshroom -> crags there's two separate paths - "Glacial Peak Portal": [["Glacial Peak Portal", "Wingsuit"], ["Glacial Peak Portal", "Rope Dart"]], # grotto -> quillshroom -> crags -> peak or crags -> peak + "Autumn Hills Portal": [["Wingsuit"]], # grotto -> bamboo -> catacombs -> hills + "Riviere Turquoise Portal": [["Candle", "Wingsuit", "Rope Dart"]], # hills -> catacombs -> dark cave -> riviere + "Howling Grotto Portal": [["Wingsuit"], ["Meditation", "Second Wind"]], # crags -> quillshroom -> grotto + "Sunken Shrine Portal": [["Seashell"]], # crags -> quillshroom -> grotto -> shrine + "Searing Crags Portal": [["Wingsuit"], ["Rope Dart"]], # grotto -> quillshroom -> crags there's two separate paths + "Glacial Peak Portal": [["Wingsuit", "Second Wind", "Meditation"], ["Rope Dart"]], # grotto -> quillshroom -> crags -> peak or crags -> peak } + for portal in PORTALS: name = f"{portal} Portal" entrance_name = f"ToTHQ {name}" @@ -23,5 +24,11 @@ def test_portal_reqs(self) -> None: entrance = self.multiworld.get_entrance(entrance_name, self.player) # this emulates the portal being initially closed entrance.access_rule = lambda state: state.has(name, self.player) - self.assertAccessDependency([name], portal_requirements[name], True) + for grouping in portal_requirements[name]: + test_state = CollectionState(self.multiworld) + self.assertFalse(entrance.can_reach(test_state), "reachable with nothing") + items = self.get_items_by_name(grouping) + for item in items: + test_state.collect(item) + self.assertTrue(entrance.can_reach(test_state), grouping) entrance.access_rule = lambda state: True From 94ea00480dc1260880aea034f6969e8012b70c7d Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 3 Feb 2024 23:16:47 -0600 Subject: [PATCH 126/163] misc cleanup --- worlds/messenger/__init__.py | 2 +- worlds/messenger/rules.py | 1 - worlds/messenger/test/test_portals.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index e589320fd189..fe567249070f 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -234,7 +234,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "max_price": self.total_shards, "required_seals": self.required_seals, "starting_portals": self.starting_portals, - "portal_exits": getattr(self, "portal_mapping", []), + "portal_exits": self.portal_mapping, **self.options.as_dict("music_box", "death_link", "logic_level"), } return slot_data diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index ce6e489fd24a..627ef9a514f3 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -479,7 +479,6 @@ def can_leash(self, state: CollectionState) -> bool: class MessengerOOBRules(MessengerRules): - def __init__(self, world: "MessengerWorld") -> None: self.world = world self.player = world.player diff --git a/worlds/messenger/test/test_portals.py b/worlds/messenger/test/test_portals.py index 1715c388e7eb..6ebb18381331 100644 --- a/worlds/messenger/test/test_portals.py +++ b/worlds/messenger/test/test_portals.py @@ -1,6 +1,5 @@ from BaseClasses import CollectionState from . import MessengerTestBase -from .. import MessengerWorld from ..portals import PORTALS From 2b4a15a1636828b177d48d844d9a7a04df4fb5da Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 09:25:37 -0600 Subject: [PATCH 127/163] fix asset grabbing of latest version --- worlds/messenger/client_setup.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 4e6947dcbc3f..73752c53637f 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -91,7 +91,7 @@ def install_mod() -> None: get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" assets = request_data(get_url)[0]["assets"] for asset in assets: - if "TheMessengerRandomizerModAP" in asset["name"]: + if "TheMessengerRandomizerAP" in asset["name"]: release_url = asset["browser_download_url"] break else: @@ -115,7 +115,7 @@ def available_mod_update() -> bool: if asset["label"]: latest_version = asset["label"] break - names = asset["name"].split("-") + names = asset["name"].strip(".zip").split("-") if len(names) > 2: if names[-1].isnumeric(): latest_version = names[-1] @@ -131,6 +131,7 @@ def available_mod_update() -> bool: with open(toml_path, "r") as f: installed_version = f.read().splitlines()[1].strip("version = \"") + logging.info(f"Installed version: {installed_version}. Latest version: {latest_version}") if not installed_version.isnumeric(): if installed_version[-1].isnumeric(): installed_version = installed_version[-1] @@ -148,20 +149,22 @@ def available_mod_update() -> bool: "No Courier installation detected. Would you like to install now?") if not should_install: return + logging.info("Installing Courier") install_courier() if not mod_installed(): should_install = askyesnocancel("Install Mod", "No randomizer mod detected. Would you like to install now?") if not should_install: return + logging.info("Installing Mod") install_mod() else: if available_mod_update(): should_update = askyesnocancel("Update Mod", "Old mod version detected. Would you like to update now?") if should_update: + logging.info("Updating mod") install_mod() - logging.info(url) if is_linux: if url: open_file(f"steam://rungameid/764790//{url}/") From 90fd209c9db6f06afab1e8a0d82736a880890553 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 12:34:25 -0600 Subject: [PATCH 128/163] implement ER --- worlds/messenger/__init__.py | 22 +++++++--- worlds/messenger/connections.py | 73 +++++++++++++++++++++++++++++++++ worlds/messenger/entrances.py | 49 ++++++++++++++++++++++ worlds/messenger/options.py | 19 ++++++++- worlds/messenger/portals.py | 4 +- 5 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 worlds/messenger/entrances.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index fe567249070f..6469968dd493 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any, ClassVar, Dict, List, Optional, TextIO -from BaseClasses import CollectionState, Item, ItemClassification, Tutorial +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Tutorial from Options import Accessibility from Utils import visualize_regions, output_path from settings import FilePath, Group @@ -10,7 +10,8 @@ from .client_setup import launch_game from .connections import CONNECTIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded +from .entrances import shuffle_entrances +from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules @@ -94,6 +95,8 @@ class MessengerWorld(World): starting_portals: List[str] spoiler_portal_mapping: Dict[str, str] portal_mapping: List[int] + spoiler_entrances: Dict[str, str] + transitions: List[int] def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: @@ -113,6 +116,8 @@ def generate_early(self) -> None: self.random.sample(PORTALS[3:], k=self.options.available_portals - 3)] self.portal_mapping = [] self.spoiler_portal_mapping = {} + self.spoiler_entrances = {} + self.transitions = [] def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld @@ -200,19 +205,26 @@ def set_rules(self) -> None: add_closed_portal_reqs(self) # i need ER to happen after rules exist so i can validate it if self.options.shuffle_portals: - self.__class__.topology_present = True disconnect_portals(self) shuffle_portals(self) + if self.options.shuffle_transitions: + shuffle_entrances(self) + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: spoiler_handle.write(f"\nStarting Portals:\n") for portal in self.starting_portals: spoiler_handle.write(f"{portal}\n") + spoiler = self.multiworld.spoiler if self.options.shuffle_portals: - spoiler_handle.write(f"\nPortal Warps:\n") for portal, output in self.spoiler_portal_mapping.items(): - spoiler_handle.write(f"{portal + ' Portal:':33}{output}\n") + spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here and it'll work fine lmao", self.player) + if self.options.shuffle_transitions: + for entrance, exit_ in self.spoiler_entrances.items(): + direction = "both" if (self.multiworld.get_entrance(f"{entrance} -> {exit_}", self.player) + .er_type == Entrance.EntranceType.TWO_WAY) else "lol lmao" + spoiler.set_entrance(entrance, exit_, direction, self.player) def generate_output(self, output_directory: str) -> None: out_path = output_path(self.multiworld.get_out_file_name_base(self.player) + ".aptm") diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 485c2285215e..822b0affd1ae 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -594,3 +594,76 @@ ], }, } + +RANDOMIZED_CONNECTIONS: Dict[str, str] = { + "Ninja Village - Right": "Autumn Hills - Left", + "Autumn Hills - Left": "Ninja Village - Right", + "Autumn Hills - Right": "Forlorn Temple - Left", + "Autumn Hills - Bottom": "Catacombs - Bottom Left", + "Forlorn Temple - Left": "Autumn Hills - Right", + "Forlorn Temple - Right": "Bamboo Creek - Top Left", + "Forlorn Temple - Bottom": "Catacombs - Top Left", + "Catacombs - Top Left": "Forlorn Temple - Bottom", + "Catacombs - Bottom Left": "Autumn Hills - Bottom", + "Catacombs - Bottom": "Dark Cave - Right", + "Catacombs - Right": "Bamboo Creek - Bottom Left", + "Bamboo Creek - Bottom Left": "Catacombs - Right", + "Bamboo Creek - Right": "Howling Grotto - Left", + "Howling Grotto - Left": "Bamboo Creek - Right", + "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", + "Howling Grotto - Right": "Quillshroom Marsh - Top Left", + "Howling Grotto - Bottom": "Sunken Shrine - Left", + "Quillshroom Marsh - Top Left": "Howling Grotto - Right", + "Quillshroom Marsh - Bottom Left": "Howling Grotto - Top", + "Quillshroom Marsh - Top Right": "Searing Crags - Left", + "Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom", + "Searing Crags - Left": "Quillshroom Marsh - Top Right", + "Searing Crags - Top": "Glacial Peak - Bottom", + "Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right", + "Searing Crags - Right": "Underworld - Left", + "Glacial Peak - Bottom": "Searing Crags - Top", + "Glacial Peak - Top": "Cloud Ruins - Left", + # "ToTHQ": "Tower of Time", + "Underworld - Left": "Searing Crags - Right", + "Dark Cave - Right": "Catacombs - Bottom", + "Dark Cave - Left": "Riviere Turquoise - Right", + "Sunken Shrine - Left": "Howling Grotto - Bottom", +} + +TRANSITIONS: List[str] = [ + "Ninja Village - Right", + "Autumn Hills - Left", + "Autumn Hills - Right", + "Autumn Hills - Bottom", + "Forlorn Temple - Left", + "Forlorn Temple - Bottom", + "Forlorn Temple - Right", + "Catacombs - Top Left", + "Catacombs - Right", + "Catacombs - Bottom", + "Catacombs - Bottom Left", + "Dark Cave - Right", + "Dark Cave - Left", + "Riviere Turquoise - Right", + "Howling Grotto - Left", + "Howling Grotto - Right", + "Howling Grotto - Top", + "Howling Grotto - Bottom", + "Sunken Shrine - Left", + "Bamboo Creek - Top Left", + "Bamboo Creek - Bottom Left", + "Bamboo Creek - Right", + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Top Right", + "Quillshroom Marsh - Bottom Right", + "Searing Crags - Left", + "Searing Crags - Bottom", + "Searing Crags - Right", + "Searing Crags - Top", + "Glacial Peak - Bottom", + "Glacial Peak - Top", + "Tower of Time - Left", + "Cloud Ruins - Left", + "Underworld - Left", +] diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py new file mode 100644 index 000000000000..e615702706bb --- /dev/null +++ b/worlds/messenger/entrances.py @@ -0,0 +1,49 @@ +from typing import Dict, List, TYPE_CHECKING + +from BaseClasses import Entrance, Region +from EntranceRando import randomize_entrances +from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS +from .options import ShuffleTransitions + +if TYPE_CHECKING: + from . import MessengerWorld + + +def shuffle_entrances(world: "MessengerWorld") -> Dict[str, str]: + multiworld = world.multiworld + player = world.player + coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled + + def disconnect_entrance() -> None: + child_region.entrances.remove(entrance) + entrance.connected_region = None + + er_type = Entrance.EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else Entrance.EntranceType.ONE_WAY + if er_type == Entrance.EntranceType.TWO_WAY: + mock_entrance = parent_region.create_er_target(entrance.name) + else: + mock_entrance = child_region.create_er_target(child) + + entrance.er_type = er_type + mock_entrance.er_type = er_type + + regions_to_shuffle: List[Region] = [] + for parent, child in RANDOMIZED_CONNECTIONS.items(): + + entrance = multiworld.get_entrance(f"{parent} -> {child}", player) + parent_region = entrance.parent_region + child_region = entrance.connected_region + disconnect_entrance() + regions_to_shuffle += [parent_region, child_region] + + result = randomize_entrances(world, list(set(regions_to_shuffle)), coupled, lambda group: ["Default"]) + + for placement in sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)): + parent = placement.parent_region + child = placement.connected_region + # recache the entrance with a new name + parent.exits.remove(placement) + placement.name = f"{parent.name} -> {child.name}" + parent.exits.append(placement) + world.spoiler_entrances[parent.name] = child.name + world.transitions.append(TRANSITIONS.index(child.name)) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 1b12a32b195f..0334a6e91a24 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -53,7 +53,7 @@ class AvailablePortals(Range): default = 6 -class ShufflePortals(TextChoice): +class ShufflePortals(Choice): """ Whether the portals lead to random places. Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant. @@ -72,6 +72,22 @@ class ShufflePortals(TextChoice): default = 0 +class ShuffleTransitions(Choice): + """ + Whether the transitions between the levels should be randomized. + + None: Level transitions lead where they should. + Coupled: Returning through a transition will take you from whence you came. + Decoupled: Any level transition can take you to any other level transition. + """ + display_name = "Shuffle Level Transitions" + option_none = 0 + alias_off = 0 + option_coupled = 1 + option_decoupled = 2 + default = 1 + + class Goal(Choice): """Requirement to finish the game.""" display_name = "Goal" @@ -176,6 +192,7 @@ class MessengerOptions(PerGameCommonOptions): early_meditation: EarlyMed available_portals: AvailablePortals shuffle_portals: ShufflePortals + shuffle_transitions: ShuffleTransitions goal: Goal music_box: MusicBox notes_needed: NotesNeeded diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 1f4cbdca9373..af91bf74f4a0 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -83,11 +83,11 @@ "Tower Entrance", ], "Tower of Time": [ - "Entrance", + "Final Chance", "Arcane Golem", ], "Cloud Ruins": [ - "Entrance", + "Cloud Entrance", "Pillar Glide", "Crushers' Descent", "Seeing Spikes", From dbb7ba80ccec17ada2523f11b5d043401794a8bc Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 12:57:54 -0600 Subject: [PATCH 129/163] just use the entrances for the spoiler instead of manipulating the cache --- worlds/messenger/__init__.py | 13 +++++++------ worlds/messenger/entrances.py | 14 ++++---------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 6469968dd493..cb02e21eabe7 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -95,8 +95,7 @@ class MessengerWorld(World): starting_portals: List[str] spoiler_portal_mapping: Dict[str, str] portal_mapping: List[int] - spoiler_entrances: Dict[str, str] - transitions: List[int] + transitions: List[Entrance] def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: @@ -221,10 +220,12 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: for portal, output in self.spoiler_portal_mapping.items(): spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here and it'll work fine lmao", self.player) if self.options.shuffle_transitions: - for entrance, exit_ in self.spoiler_entrances.items(): - direction = "both" if (self.multiworld.get_entrance(f"{entrance} -> {exit_}", self.player) - .er_type == Entrance.EntranceType.TWO_WAY) else "lol lmao" - spoiler.set_entrance(entrance, exit_, direction, self.player) + for transition in self.transitions: + spoiler.set_entrance( + transition.parent_region.name, + transition.connected_region.name, + "both" if transition.er_type == Entrance.EntranceType.TWO_WAY else "", + self.player) def generate_output(self, output_directory: str) -> None: out_path = output_path(self.multiworld.get_out_file_name_base(self.player) + ".aptm") diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index e615702706bb..3647f48b9639 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -9,7 +9,7 @@ from . import MessengerWorld -def shuffle_entrances(world: "MessengerWorld") -> Dict[str, str]: +def shuffle_entrances(world: "MessengerWorld") -> None: multiworld = world.multiworld player = world.player coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled @@ -38,12 +38,6 @@ def disconnect_entrance() -> None: result = randomize_entrances(world, list(set(regions_to_shuffle)), coupled, lambda group: ["Default"]) - for placement in sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)): - parent = placement.parent_region - child = placement.connected_region - # recache the entrance with a new name - parent.exits.remove(placement) - placement.name = f"{parent.name} -> {child.name}" - parent.exits.append(placement) - world.spoiler_entrances[parent.name] = child.name - world.transitions.append(TRANSITIONS.index(child.name)) + world.transitions = [placement for placement in + sorted(result.placements, + key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name))] From e2866b17370356d7d35a2c7ce529672eed92c165 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 12:58:05 -0600 Subject: [PATCH 130/163] remove test defaults --- worlds/messenger/options.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 0334a6e91a24..720f8c1647ea 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -69,7 +69,6 @@ class ShufflePortals(Choice): option_shops = 1 option_checkpoints = 2 option_anywhere = 3 - default = 0 class ShuffleTransitions(Choice): @@ -85,7 +84,6 @@ class ShuffleTransitions(Choice): alias_off = 0 option_coupled = 1 option_decoupled = 2 - default = 1 class Goal(Choice): From 981d2784ac0848cb9c8c34abf1de8998cd2967b9 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 12:59:08 -0600 Subject: [PATCH 131/163] remove excessive comprehension --- worlds/messenger/__init__.py | 2 +- worlds/messenger/entrances.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index cb02e21eabe7..89dbf9cda1b8 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -240,7 +240,7 @@ def generate_output(self, output_directory: str) -> None: dump(data, f) def fill_slot_data(self) -> Dict[str, Any]: - # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.toml", show_entrance_names=True) + # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.puml", show_entrance_names=True) slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 3647f48b9639..7680153743b2 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -38,6 +38,4 @@ def disconnect_entrance() -> None: result = randomize_entrances(world, list(set(regions_to_shuffle)), coupled, lambda group: ["Default"]) - world.transitions = [placement for placement in - sorted(result.placements, - key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name))] + world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) From 3647fb41d34f93d41764f4e6fa4ed664ca74a38c Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 16:11:38 -0600 Subject: [PATCH 132/163] cleanup and cater data for the client --- worlds/messenger/__init__.py | 54 ++++++++++++++++++++------------- worlds/messenger/connections.py | 3 +- worlds/messenger/entrances.py | 4 +-- worlds/messenger/regions.py | 2 +- worlds/messenger/subclasses.py | 5 +++ 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 89dbf9cda1b8..7e16bc9c9690 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,14 +1,14 @@ import logging from typing import Any, ClassVar, Dict, List, Optional, TextIO -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Tutorial +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial from Options import Accessibility from Utils import visualize_regions, output_path from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components from .client_setup import launch_game -from .connections import CONNECTIONS +from .connections import CONNECTIONS, RANDOMIZED_CONNECTIONS, TRANSITIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS from .entrances import shuffle_entrances from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions @@ -115,9 +115,17 @@ def generate_early(self) -> None: self.random.sample(PORTALS[3:], k=self.options.available_portals - 3)] self.portal_mapping = [] self.spoiler_portal_mapping = {} - self.spoiler_entrances = {} self.transitions = [] + @classmethod + def stage_generate_early(cls, multiworld: MultiWorld): + if multiworld.players > 1: + return + out_path = output_path(multiworld.get_out_file_name_base(1) + ".aptm") + if "The Messenger\\Archipelago\\output" in out_path: + cls.out_path = out_path + cls.generate_output = generate_output + def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld # create simple regions @@ -136,6 +144,9 @@ def create_regions(self) -> None: region = self.multiworld.get_region(region_name, self.player) region.add_exits(REGION_CONNECTIONS[region.name]) + if self.options.shuffle_transitions: + shuffle_entrances(self) + def create_items(self) -> None: # create items that are always in the item pool main_movement_items = ["Rope Dart", "Wingsuit"] @@ -193,7 +204,6 @@ def create_items(self) -> None: self.multiworld.itempool += filler def set_rules(self) -> None: - # MessengerRules(self).set_messenger_rules() logic = self.options.logic_level if logic == Logic.option_normal: MessengerRules(self).set_messenger_rules() @@ -201,15 +211,13 @@ def set_rules(self) -> None: MessengerHardRules(self).set_messenger_rules() # else: # MessengerOOBRules(self).set_messenger_rules() + add_closed_portal_reqs(self) - # i need ER to happen after rules exist so i can validate it + # i need portal shuffle to happen after rules exist so i can validate it if self.options.shuffle_portals: disconnect_portals(self) shuffle_portals(self) - if self.options.shuffle_transitions: - shuffle_entrances(self) - def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: spoiler_handle.write(f"\nStarting Portals:\n") @@ -227,20 +235,8 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: "both" if transition.er_type == Entrance.EntranceType.TWO_WAY else "", self.player) - def generate_output(self, output_directory: str) -> None: - out_path = output_path(self.multiworld.get_out_file_name_base(self.player) + ".aptm") - if self.multiworld.players > 1 or "The Messenger\\Archipelago\\output" not in out_path: - return - from json import dump - data = { - "slot_data": self.fill_slot_data(), - "loc_data": {loc.address: {loc.item.code: loc.item.name} for loc in self.multiworld.get_filled_locations() if loc.address}, - } - with open(out_path, "w") as f: - dump(data, f) - def fill_slot_data(self) -> Dict[str, Any]: - # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.puml", show_entrance_names=True) + # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.puml") slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, @@ -248,6 +244,9 @@ def fill_slot_data(self) -> Dict[str, Any]: "required_seals": self.required_seals, "starting_portals": self.starting_portals, "portal_exits": self.portal_mapping, + "transitions": [[TRANSITIONS.index(RANDOMIZED_CONNECTIONS[transition.parent_region.name]), + TRANSITIONS.index(transition.connected_region.name)] + for transition in self.transitions], **self.options.as_dict("music_box", "death_link", "logic_level"), } return slot_data @@ -304,3 +303,16 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: if change and "Time Shard" in item.name: state.prog_items[self.player]["Shards"] -= int(item.name.strip("Time Shard ()")) return change + + +def generate_output(world: MessengerWorld, output_directory: str) -> None: + import orjson + data = { + "slot_data": world.fill_slot_data(), + "loc_data": {loc.address: {loc.item.code: loc.item.name} for loc in world.multiworld.get_filled_locations() + if loc.address}, + } + + output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) + with open(world.out_path, "wb") as f: + f.write(output) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 822b0affd1ae..d9cf5eea6b73 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -623,7 +623,7 @@ "Searing Crags - Right": "Underworld - Left", "Glacial Peak - Bottom": "Searing Crags - Top", "Glacial Peak - Top": "Cloud Ruins - Left", - # "ToTHQ": "Tower of Time", + # "Tower HQ": "Tower of Time - Left", # this entrance functions weird so skip for now "Underworld - Left": "Searing Crags - Right", "Dark Cave - Right": "Catacombs - Bottom", "Dark Cave - Left": "Riviere Turquoise - Right", @@ -663,6 +663,7 @@ "Searing Crags - Top", "Glacial Peak - Bottom", "Glacial Peak - Top", + "Tower HQ", "Tower of Time - Left", "Cloud Ruins - Left", "Underworld - Left", diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 7680153743b2..6587443d2518 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -1,4 +1,4 @@ -from typing import Dict, List, TYPE_CHECKING +from typing import List, TYPE_CHECKING from BaseClasses import Entrance, Region from EntranceRando import randomize_entrances @@ -36,6 +36,6 @@ def disconnect_entrance() -> None: disconnect_entrance() regions_to_shuffle += [parent_region, child_region] - result = randomize_entrances(world, list(set(regions_to_shuffle)), coupled, lambda group: ["Default"]) + result = randomize_entrances(world, sorted(set(regions_to_shuffle)), coupled, lambda group: ["Default"]) world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 3b0a7c7fe522..0a68cfa6c18f 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -398,7 +398,7 @@ "Howling Grotto - Portal": "ToTHQ Howling Grotto Portal", "Searing Crags - Portal": "ToTHQ Searing Crags Portal", "Glacial Peak - Portal": "ToTHQ Glacial Peak Portal", - "Tower of Time - Left": "ToTHQ -> Tower of Time", + "Tower of Time - Left": "Tower HQ -> Tower of Time - Left", "Riviere Turquoise - Portal": "ToTHQ Riviere Turquoise Portal", "Sunken Shrine - Portal": "ToTHQ Sunken Shrine Portal", "Corrupted Future": "Artificer's Portal", diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index b31348d1812b..43bce86c7adc 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -40,6 +40,11 @@ def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = N self.multiworld.regions.append(self) + def __lt__(self, other): + if isinstance(other, MessengerRegion): + return self.name < other.name + return self.name < other + class MessengerLocation(Location): game = "The Messenger" From 1553a9e7b038541c5622fce3ed73f09d17abb2a7 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 4 Feb 2024 22:49:45 -0600 Subject: [PATCH 133/163] add elemental skylands to the shuffle pools --- worlds/messenger/__init__.py | 12 ++++--- worlds/messenger/connections.py | 57 +++++++++++++++++++++++++++++++-- worlds/messenger/entrances.py | 5 ++- worlds/messenger/options.py | 2 +- worlds/messenger/portals.py | 16 ++++++++- worlds/messenger/regions.py | 30 ++++++++++++++--- worlds/messenger/rules.py | 15 ++++++--- 7 files changed, 119 insertions(+), 18 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 7e16bc9c9690..652ce4de9226 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -3,7 +3,7 @@ from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial from Options import Accessibility -from Utils import visualize_regions, output_path +from Utils import output_path from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components @@ -230,13 +230,14 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.shuffle_transitions: for transition in self.transitions: spoiler.set_entrance( - transition.parent_region.name, + transition.name if transition.name == "Artificer's Portal" else transition.parent_region.name, transition.connected_region.name, - "both" if transition.er_type == Entrance.EntranceType.TWO_WAY else "", + "both" if transition.er_type == Entrance.EntranceType.TWO_WAY + and self.options.shuffle_transitions == ShuffleTransitions.option_coupled + else "", self.player) def fill_slot_data(self) -> Dict[str, Any]: - # visualize_regions(self.multiworld.get_region("Menu", self.player), "output.puml") slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, @@ -244,7 +245,8 @@ def fill_slot_data(self) -> Dict[str, Any]: "required_seals": self.required_seals, "starting_portals": self.starting_portals, "portal_exits": self.portal_mapping, - "transitions": [[TRANSITIONS.index(RANDOMIZED_CONNECTIONS[transition.parent_region.name]), + "transitions": [[TRANSITIONS.index("Corrupted Future") if transition.name == "Artificer's Portal" + else TRANSITIONS.index(RANDOMIZED_CONNECTIONS[transition.parent_region.name]), TRANSITIONS.index(transition.connected_region.name)] for transition in self.transitions], **self.options.as_dict("music_box", "death_link", "logic_level"), diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index d9cf5eea6b73..e90fe3b76455 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -352,7 +352,7 @@ "Glacial Peak - Ice Climbers' Shop", ], "Left": [ - "Elemental Skylands", + "Elemental Skylands - Air Shmup", "Glacial Peak - Projectile Spike Pit Checkpoint", "Glacial Peak - Glacial Mega Shard Shop", ], @@ -545,6 +545,52 @@ "Riviere Turquoise - Launch of Faith Shop", ], }, + "Elemental Skylands": { + "Air Shmup": [ + "Elemental Skylands - Air Intro Shop", + ], + "Air Intro Shop": [ + "Elemental Skylands - Air Seal Checkpoint", + "Elemental Skylands - Air Generator Shop", + ], + "Air Seal Checkpoint": [ + "Elemental Skylands - Air Intro Shop", + "Elemental Skylands - Air Generator Shop", + ], + "Air Generator Shop": [ + "Elemental Skylands - Earth Shmup", + ], + "Earth Shmup": [ + "Elemental Skylands - Earth Intro Shop", + ], + "Earth Intro Shop": [ + "Elemental Skylands - Earth Generator Shop", + ], + "Earth Generator Shop": [ + "Elemental Skylands - Fire Shmup", + ], + "Fire Shmup": [ + "Elemental Skylands - Fire Intro Shop", + ], + "Fire Intro Shop": [ + "Elemental Skylands - Fire Generator Shop", + ], + "Fire Generator Shop": [ + "Elemental Skylands - Water Shmup", + ], + "Water Shmup": [ + "Elemental Skylands - Water Intro Shop", + ], + "Water Intro Shop": [ + "Elemental Skylands - Water Generator Shop", + ], + "Water Generator Shop": [ + "Elemental Skylands - Right", + ], + "Right": [ + "Glacial Peak - Left", + ], + }, "Sunken Shrine": { "Left": [ "Howling Grotto - Bottom", @@ -623,7 +669,10 @@ "Searing Crags - Right": "Underworld - Left", "Glacial Peak - Bottom": "Searing Crags - Top", "Glacial Peak - Top": "Cloud Ruins - Left", - # "Tower HQ": "Tower of Time - Left", # this entrance functions weird so skip for now + "Glacial Peak - Left": "Elemental Skylands - Air Shmup", + # "Elemental Skylands - Right": "Glacial Peak - Left", + "Tower HQ": "Tower of Time - Left", # this entrance functions weird so skip for now + "Artificer": "Corrupted Future", "Underworld - Left": "Searing Crags - Right", "Dark Cave - Right": "Catacombs - Bottom", "Dark Cave - Left": "Riviere Turquoise - Right", @@ -663,8 +712,12 @@ "Searing Crags - Top", "Glacial Peak - Bottom", "Glacial Peak - Top", + "Glacial Peak - Left", + "Elemental Skylands - Air Shmup", + "Elemental Skylands - Right", "Tower HQ", "Tower of Time - Left", + "Corrupted Future", "Cloud Ruins - Left", "Underworld - Left", ] diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 6587443d2518..28aa059fe053 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -30,7 +30,10 @@ def disconnect_entrance() -> None: regions_to_shuffle: List[Region] = [] for parent, child in RANDOMIZED_CONNECTIONS.items(): - entrance = multiworld.get_entrance(f"{parent} -> {child}", player) + if child == "Corrupted Future": + entrance = multiworld.get_entrance("Artificer's Portal", player) + else: + entrance = multiworld.get_entrance(f"{parent} -> {child}", player) parent_region = entrance.parent_region child_region = entrance.connected_region disconnect_entrance() diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 720f8c1647ea..3b3693fa543c 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -24,7 +24,7 @@ class Logic(Choice): display_name = "Logic Level" option_normal = 0 option_hard = 1 - option_oob = 2 + alias_oob = 1 alias_challenging = 1 diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index af91bf74f4a0..4cc2e0639c17 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -1,4 +1,4 @@ -from typing import Dict, TYPE_CHECKING +from typing import TYPE_CHECKING from BaseClasses import CollectionState from .options import ShufflePortals @@ -30,6 +30,7 @@ "Cloud Ruins", "Underworld", "Riviere Turquoise", + "Elemental Skylands", "Sunken Shrine", ] @@ -109,6 +110,16 @@ "Restock", "Butterfly Matriarch", ], + "Elemental Skylands": [ + "Air Intro", + "Air Generator", + "Earth Intro", + "Earth Generator", + "Fire Intro", + "Fire Generator", + "Water Intro", + "Water Generator", + ], "Sunken Shrine": [ "Above Portal", "Lifeguard", @@ -180,6 +191,9 @@ "Riviere Turquoise": [ "Flower Flight", ], + "Elemental Skylands": [ + "Air Seal", + ], "Sunken Shrine": [ "Lightfoot Tabi", "Sun Crest", diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 0a68cfa6c18f..a6e824fae517 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -184,10 +184,16 @@ "Sunken Shrine - Moon Crest Checkpoint": [ "Sunken Shrine - Moon Crest", ], - "Elemental Skylands": [ + "Elemental Skylands - Air Seal Checkpoint": [ "Elemental Skylands Seal - Air", + ], + "Elemental Skylands - Water Intro Shop": [ "Elemental Skylands Seal - Water", + ], + "Elemental Skylands - Fire Intro Shop": [ "Elemental Skylands Seal - Fire", + ], + "Elemental Skylands - Right": [ "Elemental Skylands - Key of Symbiosis", ], "Corrupted Future": ["Corrupted Future - Key of Courage"], @@ -346,6 +352,22 @@ "Butterfly Matriarch Shop", "Flower Flight Checkpoint", ], + "Elemental Skylands": [ + "Air Shmup", + "Air Intro Shop", + "Air Seal Checkpoint", + "Air Generator Shop", + "Earth Shmup", + "Earth Intro Shop", + "Earth Generator Shop", + "Fire Shmup", + "Fire Intro Shop", + "Fire Generator Shop", + "Water Shmup", + "Water Intro Shop", + "Water Generator Shop", + "Right", + ], "Sunken Shrine": [ "Left", "Portal", @@ -387,7 +409,8 @@ "Sunken Shrine - Sun Crest Checkpoint": ["Mega Shard of the Sun"], "Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"], "Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], - "Elemental Skylands": ["Earth Mega Shard", "Water Mega Shard"], + "Elemental Skylands - Earth Intro Shop": ["Earth Mega Shard"], + "Elemental Skylands - Water Generator Shop": ["Water Mega Shard"], } @@ -412,13 +435,12 @@ """Vanilla layout mapping with all Tower HQ portals open. format is source[exit_region][entrance_name]""" -# regions that don't have sub-regions and their exits +# regions that don't have sub-regions LEVELS: List[str] = [ "Menu", "Tower HQ", "The Shop", "The Craftsman's Corner", - "Elemental Skylands", "Corrupted Future", "Music Box", ] diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 627ef9a514f3..a4b17f2fbcb3 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -125,11 +125,10 @@ def __init__(self, world: "MessengerWorld") -> None: # Glacial Peak "Glacial Peak - Portal -> Glacial Peak - Tower Entrance Shop": self.has_vertical, - "Glacial Peak - Left -> Elemental Skylands": # if we can ever shuffle elemental skylands stuff around wingsuit isn't needed here. + "Glacial Peak - Left -> Elemental Skylands - Air Shmup": lambda state: state.has("Magic Firefly", self.player) and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player) - .can_reach(state) - and self.has_wingsuit(state), + .can_reach(state), "Glacial Peak - Tower Entrance Shop -> Glacial Peak - Top": lambda state: state.has("Ruxxtin's Amulet", self.player), "Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left": @@ -202,6 +201,11 @@ def __init__(self, world: "MessengerWorld") -> None: lambda state: self.has_dart(state) and self.can_dboost(state), "Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop": lambda state: False, + # Elemental Skylands + "Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Seal Checkpoint": + self.has_wingsuit, + "Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop": + self.has_wingsuit, # Sunken Shrine "Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop": self.has_tabi, @@ -398,7 +402,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": self.true, # Glacial Peak - "Glacial Peak - Left -> Elemental Skylands": + "Glacial Peak - Left -> Elemental Skylands - Air Shmup": lambda state: self.has_windmill(state) or (state.has("Magic Firefly", self.player) and state.multiworld.get_location( @@ -409,6 +413,9 @@ def __init__(self, world: "MessengerWorld") -> None: # Cloud Ruins "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint": self.true, + # Elemental Skylands + "Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop": + self.true, # Riviere Turquoise "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": self.true, From 56687725b59c7c0296dc0deb7e10ea59927541d8 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 5 Feb 2024 12:41:38 -0600 Subject: [PATCH 134/163] initial attempts at hint text --- worlds/messenger/__init__.py | 52 +++++++++++++++++++++++++++++---- worlds/messenger/connections.py | 2 +- worlds/messenger/subclasses.py | 5 ++-- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 652ce4de9226..e2851b87787c 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any, ClassVar, Dict, List, Optional, TextIO -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial from Options import Accessibility from Utils import output_path from settings import FilePath, Group @@ -121,10 +121,7 @@ def generate_early(self) -> None: def stage_generate_early(cls, multiworld: MultiWorld): if multiworld.players > 1: return - out_path = output_path(multiworld.get_out_file_name_base(1) + ".aptm") - if "The Messenger\\Archipelago\\output" in out_path: - cls.out_path = out_path - cls.generate_output = generate_output + cls.generate_output = generate_output def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld @@ -227,8 +224,11 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.shuffle_portals: for portal, output in self.spoiler_portal_mapping.items(): spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here and it'll work fine lmao", self.player) + if self.options.shuffle_transitions: for transition in self.transitions: + if (transition.connected_region.name, "both", self.player) in spoiler.entrances: + continue spoiler.set_entrance( transition.name if transition.name == "Artificer's Portal" else transition.parent_region.name, transition.connected_region.name, @@ -237,6 +237,43 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: else "", self.player) + # def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: + # if not self.options.shuffle_transitions: + # return + # + # region_data = {} + # for region in self.multiworld.get_regions(self.player): + # checked_entrances = set() + # + # def check_entrance(entry: Entrance): + # if entry in self.transitions or entry.parent_region.name == "Tower HQ": + # return True + # for entrance in entry.parent_region.entrances: + # if entrance not in checked_entrances: + # checked_entrances.add(entrance) + # ret = check_entrance(entrance) + # if ret is True: + # return entrance.name if entrance.parent_region.name == "Tower HQ" else entrance.parent_region.name + # elif ret is not None: + # return ret + # return None + # if region.name in {"Tower HQ", "The Shop", "The Craftsman's Corner"} + # continue + # region_data[region.name] = check_entrance(region.) + # def is_main_entrance(entry: Entrance) -> bool: + # return entry in self.transitions or entry.parent_region.name == "Tower HQ" + # + # for loc in self.multiworld.get_locations(self.player): + # if not loc.address or loc.parent_region.name in {"Tower HQ", "The Shop", "The Craftsman's Corner"}: + # continue + # hint_text = "" + # region = loc.parent_region + # while not hint_text.startswith("Tower HQ"): + # self.po + # hint_text = f"{region.name} -> {hint_text}" if hint_text else region.name + # + # hint_data[self.player] = {loc.address: hint_text} + def fill_slot_data(self) -> Dict[str, Any]: slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, @@ -308,6 +345,9 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: def generate_output(world: MessengerWorld, output_directory: str) -> None: + out_path = output_path(world.multiworld.get_out_file_name_base(1) + ".aptm") + if "The Messenger\\Archipelago\\output" in out_path: + out_path = out_path import orjson data = { "slot_data": world.fill_slot_data(), @@ -316,5 +356,5 @@ def generate_output(world: MessengerWorld, output_directory: str) -> None: } output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) - with open(world.out_path, "wb") as f: + with open(out_path, "wb") as f: f.write(output) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index e90fe3b76455..3d273f371766 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -671,7 +671,7 @@ "Glacial Peak - Top": "Cloud Ruins - Left", "Glacial Peak - Left": "Elemental Skylands - Air Shmup", # "Elemental Skylands - Right": "Glacial Peak - Left", - "Tower HQ": "Tower of Time - Left", # this entrance functions weird so skip for now + "Tower HQ": "Tower of Time - Left", "Artificer": "Corrupted Future", "Underworld - Left": "Searing Crags - Right", "Dark Cave - Right": "Catacombs - Bottom", diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 43bce86c7adc..243f16c8e91e 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -2,9 +2,8 @@ from typing import Optional, TYPE_CHECKING from BaseClasses import CollectionState, Item, ItemClassification, Location, Region -from .constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -from .regions import MEGA_SHARDS, LOCATIONS -from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS +from .regions import LOCATIONS, MEGA_SHARDS +from .shop import FIGURINES, SHOP_ITEMS if TYPE_CHECKING: from . import MessengerWorld From afd3b4bfbe30bccdf9ee51f30ef5ae116a496068 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 5 Feb 2024 18:08:56 -0600 Subject: [PATCH 135/163] use network items for offline seeds --- worlds/messenger/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index e2851b87787c..78b453ee4c6d 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,9 +1,10 @@ import logging from typing import Any, ClassVar, Dict, List, Optional, TextIO -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial from Options import Accessibility from Utils import output_path +from NetUtils import NetworkItem from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components @@ -350,9 +351,10 @@ def generate_output(world: MessengerWorld, output_directory: str) -> None: out_path = out_path import orjson data = { + "name": world.multiworld.get_player_name(world.player), "slot_data": world.fill_slot_data(), - "loc_data": {loc.address: {loc.item.code: loc.item.name} for loc in world.multiworld.get_filled_locations() - if loc.address}, + "loc_data": {loc.address: [loc.item.code, loc.address, loc.player, loc.item.flags] + for loc in world.multiworld.get_filled_locations() if loc.address}, } output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) From 900c50ca657eea0933d668eebdc1e97b55b79e46 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 5 Feb 2024 20:38:35 -0600 Subject: [PATCH 136/163] change around the offline seed data again --- worlds/messenger/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 78b453ee4c6d..4c4efb4c087a 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -353,7 +353,7 @@ def generate_output(world: MessengerWorld, output_directory: str) -> None: data = { "name": world.multiworld.get_player_name(world.player), "slot_data": world.fill_slot_data(), - "loc_data": {loc.address: [loc.item.code, loc.address, loc.player, loc.item.flags] + "loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]} for loc in world.multiworld.get_filled_locations() if loc.address}, } From 5ec5ecfe45d78737a766bedd51d067da9b16a319 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 10 Feb 2024 15:50:59 -0600 Subject: [PATCH 137/163] move er after portal shuffle and ensure a minimal sphere 1 --- BaseClasses.py | 1 + worlds/messenger/__init__.py | 39 +++++++++++++++++++++------------- worlds/messenger/entrances.py | 1 + worlds/messenger/portals.py | 2 ++ worlds/messenger/subclasses.py | 23 +++++++++++++++++++- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index b22dc95c2553..0230385e7875 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -698,6 +698,7 @@ def copy(self) -> CollectionState: ret.events = copy.copy(self.events) ret.path = copy.copy(self.path) ret.locations_checked = copy.copy(self.locations_checked) + ret.allow_partial_entrances = self.allow_partial_entrances for function in self.additional_copy_functions: ret = function(self, ret) return ret diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 4c4efb4c087a..2a0f5f5bd06a 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -4,7 +4,6 @@ from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial from Options import Accessibility from Utils import output_path -from NetUtils import NetworkItem from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components @@ -75,10 +74,10 @@ class MessengerWorld(World): "Money Wrench", ], base_offset)} item_name_groups = { - "Notes": set(NOTES), - "Keys": set(NOTES), - "Crest": {"Sun Crest", "Moon Crest"}, - "Phobe": set(PHOBEKINS), + "Notes": set(NOTES), + "Keys": set(NOTES), + "Crest": {"Sun Crest", "Moon Crest"}, + "Phobe": set(PHOBEKINS), "Phobekin": set(PHOBEKINS), } @@ -97,6 +96,7 @@ class MessengerWorld(World): spoiler_portal_mapping: Dict[str, str] portal_mapping: List[int] transitions: List[Entrance] + reachable_locs: int = 0 def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: @@ -142,9 +142,6 @@ def create_regions(self) -> None: region = self.multiworld.get_region(region_name, self.player) region.add_exits(REGION_CONNECTIONS[region.name]) - if self.options.shuffle_transitions: - shuffle_entrances(self) - def create_items(self) -> None: # create items that are always in the item pool main_movement_items = ["Rope Dart", "Wingsuit"] @@ -181,8 +178,10 @@ def create_items(self) -> None: total_seals = min(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool), self.options.total_seals.value) if total_seals < self.total_seals: - logging.warning(f"Not enough locations for total seals setting " - f"({self.options.total_seals}). Adjusting to {total_seals}") + logging.warning( + f"Not enough locations for total seals setting " + f"({self.options.total_seals}). Adjusting to {total_seals}" + ) self.total_seals = total_seals self.required_seals = int(self.options.percent_seals_required.value / 100 * self.total_seals) @@ -216,15 +215,25 @@ def set_rules(self) -> None: disconnect_portals(self) shuffle_portals(self) + if self.options.shuffle_transitions: + shuffle_entrances(self) + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: - spoiler_handle.write(f"\nStarting Portals:\n") + spoiler_handle.write(f"\nStarting Portals:\n\n") for portal in self.starting_portals: spoiler_handle.write(f"{portal}\n") spoiler = self.multiworld.spoiler if self.options.shuffle_portals: - for portal, output in self.spoiler_portal_mapping.items(): - spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here and it'll work fine lmao", self.player) + # sort the portals as they appear left to right in-game + portal_info = sorted( + self.spoiler_portal_mapping.items(), + key=lambda portal: + ["Autumn Hills", "Riviere Turquoise", + "Howling Grotto", "Sunken Shrine", + "Searing Crags", "Glacial Peak"].index(portal[0])) + for portal, output in portal_info: + spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player) if self.options.shuffle_transitions: for transition in self.transitions: @@ -347,8 +356,8 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: def generate_output(world: MessengerWorld, output_directory: str) -> None: out_path = output_path(world.multiworld.get_out_file_name_base(1) + ".aptm") - if "The Messenger\\Archipelago\\output" in out_path: - out_path = out_path + if "The Messenger\\Archipelago\\output" not in out_path: + return import orjson data = { "name": world.multiworld.get_player_name(world.player), diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 28aa059fe053..d9ddd3f9999b 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -36,6 +36,7 @@ def disconnect_entrance() -> None: entrance = multiworld.get_entrance(f"{parent} -> {child}", player) parent_region = entrance.parent_region child_region = entrance.connected_region + entrance.world = world disconnect_entrance() regions_to_shuffle += [parent_region, child_region] diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 4cc2e0639c17..6fc263816d4b 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -255,6 +255,8 @@ def disconnect_portals(world: "MessengerWorld") -> None: def validate_portals(world: "MessengerWorld") -> bool: + if world.options.shuffle_transitions: + return True new_state = CollectionState(world.multiworld) new_state.update_reachable_regions(world.player) reachable_locs = 0 diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 243f16c8e91e..514f60623c09 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -1,7 +1,7 @@ from functools import cached_property from typing import Optional, TYPE_CHECKING -from BaseClasses import CollectionState, Item, ItemClassification, Location, Region +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region from .regions import LOCATIONS, MEGA_SHARDS from .shop import FIGURINES, SHOP_ITEMS @@ -9,8 +9,29 @@ from . import MessengerWorld +class MessengerEntrance(Entrance): + world: Optional["MessengerWorld"] = None + + def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: + from . import MessengerWorld + world = getattr(self, "world", None) + if not world: + return super().can_connect_to(other, state) + assert isinstance(world, MessengerWorld) + # arbitrary minimum number + if world.reachable_locs >= 5: + return super().can_connect_to(other, state) + empty_state = CollectionState(world.multiworld, True) + self.connected_region = other.connected_region + empty_state.update_reachable_regions(world.player) + world.reachable_locs = sum(loc.can_reach(empty_state) for loc in world.multiworld.get_locations(world.player)) + self.connected_region = None + return world.reachable_locs >= 5 and super().can_connect_to(other, state) + + class MessengerRegion(Region): parent: str + entrance_type = MessengerEntrance def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = None) -> None: super().__init__(name, world.player, world.multiworld) From e8a6b94d5db53b6d541f81ad75759736f34d583d Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sat, 10 Feb 2024 17:13:56 -0800 Subject: [PATCH 138/163] Add a method to automatically disconnect entrances in a coupled-compliant way Update docs and cleanup todos --- BaseClasses.py | 11 +++-- EntranceRando.py | 103 +++++++++++++++++++++++++++++++++++--------- worlds/AutoWorld.py | 3 +- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 2477264216cb..7a76cca45e27 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -849,13 +849,18 @@ def is_valid_source_transition(self, state: "ERPlacementState") -> bool: def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: """ Determines whether a given Entrance is a valid target transition, that is, whether - the entrance randomizer is allowed to pair this Entrance to that Entrance. + the entrance randomizer is allowed to pair this Entrance to that Entrance. By default, + only allows connection between entrances of the same type (one ways only go to one ways, + two ways always go to two ways) and prevents connecting an exit to itself in coupled mode. + + Generally it is a good idea use call super().can_connect_to as one condition in any custom + implementations unless you specifically want to avoid the above behaviors. :param other: The proposed Entrance to connect to :param state: The current (partial) state of the ongoing entrance randomization - :param group_one_ways: Whether to enforce that one-ways are paired together. """ - # todo - consider allowing self-loops. currently they cause problems in coupled + # the implementation of coupled causes issues for self-loops since the reverse entrance will be the + # same as the forward entrance. In uncoupled they are ok. return self.er_type == other.er_type and (not state.coupled or self.name != other.name) def __repr__(self): diff --git a/EntranceRando.py b/EntranceRando.py index 8b85f6b75412..a66ee04c5e1a 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -9,6 +9,10 @@ from worlds.AutoWorld import World +class EntranceRandomizationError(RuntimeError): + pass + + class EntranceLookup: class GroupLookup: _lookup: Dict[str, List[Entrance]] @@ -159,28 +163,73 @@ def connect( self._connect_one_way(source_exit, target_entrance) # if we're doing coupled randomization place the reverse transition as well. if self.coupled and source_exit.er_type == Entrance.EntranceType.TWO_WAY: - # TODO - better exceptions here - maybe a custom Error class? for reverse_entrance in source_region.entrances: if reverse_entrance.name == source_exit.name: if reverse_entrance.parent_region: - raise RuntimeError("This is very bad") + raise EntranceRandomizationError( + f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " + f"because the reverse entrance is already parented to " + f"{reverse_entrance.parent_region.name}.") break else: - raise RuntimeError(f"Two way exit {source_exit.name} had no corresponding entrance in " - f"{source_exit.parent_region.name}") + raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in " + f"{source_exit.parent_region.name}") for reverse_exit in target_region.exits: if reverse_exit.name == target_entrance.name: if reverse_exit.connected_region: - raise RuntimeError("this is very bad") + raise EntranceRandomizationError( + f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " + f"because the reverse exit is already connected to " + f"{reverse_exit.connected_region.name}.") break else: - raise RuntimeError(f"Two way entrance {target_entrance.name} had no corresponding exit in " - f"{target_region.name}") + raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit " + f"in {target_region.name}.") self._connect_one_way(reverse_exit, reverse_entrance) return target_entrance, reverse_entrance return target_entrance, +def disconnect_entrances_for_randomization( + world: World, + entrances_to_split: Iterable[Union[str, Entrance]] +) -> None: + """ + Given entrances in a "vanilla" region graph, splits those entrances in such a way that will prepare them + for randomization in randomize_entrances. Specifically that means the entrance will be disconnected from its + connected region and a corresponding ER target will be created in a way that can respect coupled randomization + requirements for 2 way transitions. + + Preconditions: + 1. Every provided entrance has both a parent and child region (ie, it is fully connected) + 2. Every provided entrance has already been labeled with the appropriate entrance type + + :param world: The world owning the entrances + :param entrances_to_split: The entrances which will be disconnected in preparation for randomization. + Can be Entrance objects, names of entrances, or any mix of the 2 + """ + + for entrance in entrances_to_split: + if isinstance(entrance, str): + entrance = world.multiworld.get_entrance(entrance, world.player) + child_region = entrance.connected_region + parent_region = entrance.parent_region + + # disconnect the edge + child_region.entrances.remove(entrance) + entrance.connected_region = None + + # create the needed ER target + if entrance.er_type == Entrance.EntranceType.TWO_WAY: + # for 2-ways, create a target in the parent region with a matching name to support coupling. + # targets in the child region will be created when the other direction edge is disconnected + target = parent_region.create_er_target(entrance.name) + else: + # for 1-ways, the child region needs a target and coupling/naming is not a concern + target = child_region.create_er_target(child_region.name) + target.er_type = entrance.er_type + + def randomize_entrances( world: World, regions: Iterable[Region], @@ -192,13 +241,27 @@ def randomize_entrances( Randomizes Entrances for a single world in the multiworld. Depending on how your world is configured, this may be called as early as create_regions or - need to be called as late as pre_fill. In general, earlier is better, ie the best time to + need to be called as late as pre_fill. In general, earlier is better; the best time to randomize entrances is as soon as the preconditions are fulfilled. Preconditions: 1. All of your Regions and all of their exits have been created. 2. Placeholder entrances have been created as the targets of randomization - (each exit will be randomly paired to an entrance). + (each exit will be randomly paired to an entrance). There are 2 primary ways to do this: + * Pre-place your unrandomized region graph, then use disconnect_entrances_for_randomization + to prepare them, or + * Manually prepare your entrances for randomization. Exits to be randomized should be created + and left without a connected region. There should be an equal number of matching dummy + entrances of each entrance type in the region graph which do not have a parent; these can + easily be created with region.create_er_target(). If you plan to use coupled randomization, the names + of these placeholder entrances matter and should exactly match the name of the corresponding exit in + that region. For example, given regions R1 and R2 connected R1 --[E1]-> R2 and R2 --[E2]-> R1 when + unrandomized, then the expected inputs to coupled ER would be the following (the names of the ER targets + for one-way transitions do not matter): + * R1 --[E1]-> None + * None --[E1]-> R1 + * R2 --[E2]-> None + * None --[E2]-> R2 3. All entrances and exits have been correctly labeled as 1 way or 2 way. 4. Your Menu region is connected to your starting region. 5. All the region connections you don't want to randomize are connected; usually this @@ -243,8 +306,9 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: if "Default" not in target_groups: target_groups.append("Default") for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): - # TODO - requiring new regions is a proxy for requiring new entrances to be unlocked, which is - # not quite full fidelity so we may need to revisit this in the future + # requiring a new region is a proxy for enforcing new entrances are added, thus growing the search + # space. this is not quite a full fidelity conversion, but doesn't seem to cause issues enough + # to do more complex checks region_requirement_satisfied = (not require_new_regions or target_entrance.connected_region not in er_state.placed_regions) if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, er_state): @@ -262,11 +326,12 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: if all(e.connected_region in er_state.placed_regions for e in lookup): return False - raise RuntimeError(f"None of the available entrances are valid targets for the available exits.\n" - f"Available entrances: {lookup}\n" - f"Available exits: {placeable_exits}") + raise EntranceRandomizationError( + f"None of the available entrances are valid targets for the available exits.\n" + f"Available entrances: {lookup}\n" + f"Available exits: {placeable_exits}") - for region in regions: + for region in sorted(regions, key=lambda r: r.name): for entrance in region.entrances: if not entrance.parent_region: entrance_lookup.add(entrance) @@ -282,8 +347,6 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: while entrance_lookup.dead_ends: if not find_pairing(True, True): break - # TODO - stages 3 and 4 should ideally run "together"; i.e. without respect to dead-endedness - # as we are just trying to tie off loose ends rather than get you somewhere new # stage 3 - connect any dangling entrances that remain while entrance_lookup.others: find_pairing(False, False) @@ -291,9 +354,9 @@ def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: while entrance_lookup.dead_ends: find_pairing(True, False) - # TODO - gate this behind some condition or debug level or something for production use running_time = time.perf_counter() - start_time - logging.info(f"Completed entrance randomization for player {world.player} with " - f"name {world.multiworld.player_name[world.player]} in {running_time:.4f} seconds") + if running_time > 1.0: + logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}," + f"named {world.multiworld.player_name[world.player]}") return er_state diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 70c1ba28fe8f..fdc50acc5581 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -122,8 +122,7 @@ def _timed_call(method: Callable[..., Any], *args: Any, start = time.perf_counter() ret = method(*args) taken = time.perf_counter() - start - # TODO - change this condition back or gate it behind debug level or something for production use - if taken > 0.0: + if taken > 1.0: if player and multiworld: perf_logger.info(f"Took {taken:.4f} seconds in {method.__qualname__} for player {player}, " f"named {multiworld.player_name[player]}.") From 893068fae59ae60d272042eb535e0a517ee405bf Mon Sep 17 00:00:00 2001 From: Sean Dempsey Date: Sat, 10 Feb 2024 17:41:55 -0800 Subject: [PATCH 139/163] Make find_placeable_exits deterministic by sorting blocked_connections set --- EntranceRando.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EntranceRando.py b/EntranceRando.py index a66ee04c5e1a..f7749dff6b80 100644 --- a/EntranceRando.py +++ b/EntranceRando.py @@ -131,6 +131,7 @@ def placed_regions(self) -> Set[Region]: def find_placeable_exits(self) -> List[Entrance]: blocked_connections = self.collection_state.blocked_connections[self.world.player] + blocked_connections = sorted(blocked_connections, key=lambda x: x.name) placeable_randomized_exits = [connection for connection in blocked_connections if not connection.connected_region and connection.is_valid_source_transition(self)] From 2527ee242df7a11d3c3618be4d74c9b355f70e06 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 10 Feb 2024 20:11:10 -0600 Subject: [PATCH 140/163] add more ER transitions --- worlds/messenger/__init__.py | 3 ++- worlds/messenger/connections.py | 6 ++++-- worlds/messenger/entrances.py | 10 ++++++---- worlds/messenger/subclasses.py | 6 ------ 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 2a0f5f5bd06a..fa215c7a2b31 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -237,7 +237,8 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.shuffle_transitions: for transition in self.transitions: - if (transition.connected_region.name, "both", self.player) in spoiler.entrances: + if (transition.er_type == Entrance.EntranceType.TWO_WAY and + (transition.connected_region.name, "both", self.player) in spoiler.entrances): continue spoiler.set_entrance( transition.name if transition.name == "Artificer's Portal" else transition.parent_region.name, diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 3d273f371766..5e1871e287d2 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -166,6 +166,7 @@ ], "Top Left": [ "Bamboo Creek - Abandoned Shop", + "Forlorn Temple - Right", ], "Right": [ "Howling Grotto - Left", @@ -176,7 +177,6 @@ "Bamboo Creek - Abandoned Shop", ], "Abandoned Shop": [ - "Bamboo Creek - Top Left", "Bamboo Creek - Spike Crushers Shop", "Bamboo Creek - Spike Doors Checkpoint", ], @@ -655,6 +655,7 @@ "Catacombs - Right": "Bamboo Creek - Bottom Left", "Bamboo Creek - Bottom Left": "Catacombs - Right", "Bamboo Creek - Right": "Howling Grotto - Left", + "Bamboo Creek - Top Left": "Forlorn Temple - Right", "Howling Grotto - Left": "Bamboo Creek - Right", "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", "Howling Grotto - Right": "Quillshroom Marsh - Top Left", @@ -670,7 +671,8 @@ "Glacial Peak - Bottom": "Searing Crags - Top", "Glacial Peak - Top": "Cloud Ruins - Left", "Glacial Peak - Left": "Elemental Skylands - Air Shmup", - # "Elemental Skylands - Right": "Glacial Peak - Left", + "Cloud Ruins - Left": "Glacial Peak - Top", + "Elemental Skylands - Right": "Glacial Peak - Left", "Tower HQ": "Tower of Time - Left", "Artificer": "Corrupted Future", "Underworld - Left": "Searing Crags - Right", diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index d9ddd3f9999b..3fad404856c7 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -1,3 +1,4 @@ +import logging from typing import List, TYPE_CHECKING from BaseClasses import Entrance, Region @@ -6,7 +7,7 @@ from .options import ShuffleTransitions if TYPE_CHECKING: - from . import MessengerWorld + from . import MessengerRegion, MessengerWorld def shuffle_entrances(world: "MessengerWorld") -> None: @@ -18,7 +19,8 @@ def disconnect_entrance() -> None: child_region.entrances.remove(entrance) entrance.connected_region = None - er_type = Entrance.EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else Entrance.EntranceType.ONE_WAY + er_type = Entrance.EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \ + Entrance.EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else Entrance.EntranceType.ONE_WAY if er_type == Entrance.EntranceType.TWO_WAY: mock_entrance = parent_region.create_er_target(entrance.name) else: @@ -27,7 +29,7 @@ def disconnect_entrance() -> None: entrance.er_type = er_type mock_entrance.er_type = er_type - regions_to_shuffle: List[Region] = [] + regions_to_shuffle: List[MessengerRegion] = [] for parent, child in RANDOMIZED_CONNECTIONS.items(): if child == "Corrupted Future": @@ -40,6 +42,6 @@ def disconnect_entrance() -> None: disconnect_entrance() regions_to_shuffle += [parent_region, child_region] - result = randomize_entrances(world, sorted(set(regions_to_shuffle)), coupled, lambda group: ["Default"]) + result = randomize_entrances(world, set(regions_to_shuffle), coupled, lambda group: ["Default"]) world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 514f60623c09..ecd37e1dbe19 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -28,7 +28,6 @@ def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: self.connected_region = None return world.reachable_locs >= 5 and super().can_connect_to(other, state) - class MessengerRegion(Region): parent: str entrance_type = MessengerEntrance @@ -60,11 +59,6 @@ def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = N self.multiworld.regions.append(self) - def __lt__(self, other): - if isinstance(other, MessengerRegion): - return self.name < other.name - return self.name < other - class MessengerLocation(Location): game = "The Messenger" From 1f039bdbb876921ba9cb9186deea173100b23502 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 11 Feb 2024 15:12:25 -0600 Subject: [PATCH 141/163] fix spoiler output of portal warps --- worlds/messenger/__init__.py | 5 +++-- worlds/messenger/portals.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index fa215c7a2b31..0fe89128b28a 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -111,9 +111,10 @@ def generate_early(self) -> None: self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) + starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", "Searing Crags"] self.starting_portals = [f"{portal} Portal" - for portal in PORTALS[:3] + - self.random.sample(PORTALS[3:], k=self.options.available_portals - 3)] + for portal in starting_portals[:3] + + self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)] self.portal_mapping = [] self.spoiler_portal_mapping = {} self.transitions = [] diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 6fc263816d4b..df4f27099ef7 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -9,11 +9,11 @@ PORTALS = [ "Autumn Hills", - "Howling Grotto", - "Glacial Peak", "Riviere Turquoise", + "Howling Grotto", "Sunken Shrine", "Searing Crags", + "Glacial Peak", ] From 1449090a34dd5ff9217021343b4ef9c3e019c2fe Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 19:31:46 -0600 Subject: [PATCH 142/163] add path to hint_data --- worlds/messenger/__init__.py | 68 ++++++++++++++++------------------- worlds/messenger/entrances.py | 7 ++++ 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 0fe89128b28a..46dfc640897e 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,4 +1,5 @@ import logging +from collections import deque from typing import Any, ClassVar, Dict, List, Optional, TextIO from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial @@ -16,7 +17,7 @@ from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices -from .subclasses import MessengerItem, MessengerRegion +from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation components.append( Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True) @@ -249,42 +250,35 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: else "", self.player) - # def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: - # if not self.options.shuffle_transitions: - # return - # - # region_data = {} - # for region in self.multiworld.get_regions(self.player): - # checked_entrances = set() - # - # def check_entrance(entry: Entrance): - # if entry in self.transitions or entry.parent_region.name == "Tower HQ": - # return True - # for entrance in entry.parent_region.entrances: - # if entrance not in checked_entrances: - # checked_entrances.add(entrance) - # ret = check_entrance(entrance) - # if ret is True: - # return entrance.name if entrance.parent_region.name == "Tower HQ" else entrance.parent_region.name - # elif ret is not None: - # return ret - # return None - # if region.name in {"Tower HQ", "The Shop", "The Craftsman's Corner"} - # continue - # region_data[region.name] = check_entrance(region.) - # def is_main_entrance(entry: Entrance) -> bool: - # return entry in self.transitions or entry.parent_region.name == "Tower HQ" - # - # for loc in self.multiworld.get_locations(self.player): - # if not loc.address or loc.parent_region.name in {"Tower HQ", "The Shop", "The Craftsman's Corner"}: - # continue - # hint_text = "" - # region = loc.parent_region - # while not hint_text.startswith("Tower HQ"): - # self.po - # hint_text = f"{region.name} -> {hint_text}" if hint_text else region.name - # - # hint_data[self.player] = {loc.address: hint_text} + def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: + if not self.options.shuffle_transitions: + return + + hint_data.update({self.player: {}}) + + all_state = self.multiworld.get_all_state(True) + # sometimes some of my regions aren't in path for some reason? + all_state.update_reachable_regions(self.player) + paths = all_state.path + start = self.multiworld.get_region("Tower HQ", self.player) + start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}] + transition_names = [transition.name for transition in self.transitions] + start_connections + for loc in self.multiworld.get_locations(self.player): + if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"} + or loc.address is None): + continue + path_to_loc = [] + name, connection = paths[loc.parent_region] + while connection != ("Menu", None): + name, connection = connection + if name in transition_names: + path_to_loc.append(name) + + text = "" + for transition in reversed(path_to_loc): + text += f"{transition} => " + text = text.rstrip("=> ") + hint_data[self.player][loc.address] = text def fill_slot_data(self) -> Dict[str, Any]: slot_data = { diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 3fad404856c7..b3b98e237a75 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -45,3 +45,10 @@ def disconnect_entrance() -> None: result = randomize_entrances(world, set(regions_to_shuffle), coupled, lambda group: ["Default"]) world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) + + for transition in world.transitions: + if "->" not in transition.name: + continue + transition.parent_region.exits.remove(transition) + transition.name = f"{transition.parent_region} -> {transition.connected_region}" + transition.parent_region.exits.append(transition) From d7b08dc8f33e3cce0aa2791af390de1600d0d2cf Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 19:53:57 -0600 Subject: [PATCH 143/163] rename entrance to tot to be a bit clearer --- worlds/messenger/__init__.py | 2 +- worlds/messenger/entrances.py | 2 ++ worlds/messenger/regions.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 46dfc640897e..7957eca8be30 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -243,7 +243,7 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: (transition.connected_region.name, "both", self.player) in spoiler.entrances): continue spoiler.set_entrance( - transition.name if transition.name == "Artificer's Portal" else transition.parent_region.name, + transition.name if "->" not in transition.name else transition.parent_region.name, transition.connected_region.name, "both" if transition.er_type == Entrance.EntranceType.TWO_WAY and self.options.shuffle_transitions == ShuffleTransitions.option_coupled diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index b3b98e237a75..546868e235ce 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -34,6 +34,8 @@ def disconnect_entrance() -> None: if child == "Corrupted Future": entrance = multiworld.get_entrance("Artificer's Portal", player) + elif child == "Tower of Time - Left": + entrance = multiworld.get_entrance("Artificer's Challenge", player) else: entrance = multiworld.get_entrance(f"{parent} -> {child}", player) parent_region = entrance.parent_region diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index a6e824fae517..2470b602a83e 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -421,7 +421,7 @@ "Howling Grotto - Portal": "ToTHQ Howling Grotto Portal", "Searing Crags - Portal": "ToTHQ Searing Crags Portal", "Glacial Peak - Portal": "ToTHQ Glacial Peak Portal", - "Tower of Time - Left": "Tower HQ -> Tower of Time - Left", + "Tower of Time - Left": "Artificer's Challenge", "Riviere Turquoise - Portal": "ToTHQ Riviere Turquoise Portal", "Sunken Shrine - Portal": "ToTHQ Sunken Shrine Portal", "Corrupted Future": "Artificer's Portal", From 978d4628c4b245d1398fe4772182226ee190bc0d Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 19:57:44 -0600 Subject: [PATCH 144/163] cleanup imports and update description for hard logic --- worlds/messenger/__init__.py | 1 - worlds/messenger/entrances.py | 3 +-- worlds/messenger/options.py | 5 ++--- worlds/messenger/regions.py | 2 +- worlds/messenger/test/test_options.py | 2 -- 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 7957eca8be30..c7b41d2887e9 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,5 +1,4 @@ import logging -from collections import deque from typing import Any, ClassVar, Dict, List, Optional, TextIO from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 546868e235ce..cb2c16067af0 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -1,7 +1,6 @@ -import logging from typing import List, TYPE_CHECKING -from BaseClasses import Entrance, Region +from BaseClasses import Entrance from EntranceRando import randomize_entrances from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS from .options import ShuffleTransitions diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 3b3693fa543c..48489cb91603 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -4,7 +4,7 @@ from schema import And, Optional, Or, Schema from Options import Accessibility, Choice, DeathLink, DefaultOnToggle, OptionDict, PerGameCommonOptions, Range, \ - StartInventoryPool, TextChoice, Toggle + StartInventoryPool, Toggle class MessengerAccessibility(Accessibility): @@ -18,8 +18,7 @@ class Logic(Choice): The level of logic to use when determining what locations in your world are accessible. Normal: can require damage boosts, but otherwise approachable for someone who has beaten the game. - Hard: has leashing, normal clips, time warps and turtle boosting in logic. - OoB: places everything with the minimum amount of rules possible. Expect to do OoB. Not guaranteed completable. + Hard: expects more knowledge and tighter execution. has leashing, normal clips and much tighter d-boosting in logic. """ display_name = "Logic Level" option_normal = 0 diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 2470b602a83e..153f8510f1bd 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Union +from typing import Dict, List LOCATIONS: Dict[str, List[str]] = { diff --git a/worlds/messenger/test/test_options.py b/worlds/messenger/test/test_options.py index df426fed5602..ea84af80388f 100644 --- a/worlds/messenger/test/test_options.py +++ b/worlds/messenger/test/test_options.py @@ -1,5 +1,3 @@ -from typing import cast - from BaseClasses import CollectionState from Fill import distribute_items_restrictive from . import MessengerTestBase From 529973a00520529ac21c1cdb11e7d1df6f21ae1d Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 20:32:04 -0600 Subject: [PATCH 145/163] cleanup for PR to main --- BaseClasses.py | 63 +----- CommonClient.py | 3 +- EntranceRando.py | 363 ------------------------------- Launcher.py | 34 +-- WebHostLib/templates/macros.html | 2 +- inno_setup.iss | 4 +- worlds/LauncherComponents.py | 11 +- worlds/messenger/__init__.py | 96 ++++---- worlds/messenger/client_setup.py | 60 ++--- worlds/messenger/options.py | 2 +- worlds/messenger/portals.py | 4 +- 11 files changed, 83 insertions(+), 559 deletions(-) delete mode 100644 EntranceRando.py diff --git a/BaseClasses.py b/BaseClasses.py index e2723d3748a4..4002800173ea 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -20,7 +20,6 @@ if typing.TYPE_CHECKING: from worlds import AutoWorld - from EntranceRando import ERPlacementState class Group(TypedDict, total=False): @@ -388,12 +387,12 @@ def get_entrance(self, entrance_name: str, player: int) -> Entrance: def get_location(self, location_name: str, player: int) -> Location: return self.regions.location_cache[player][location_name] - def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState: + def get_all_state(self, use_cache: bool) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: return cached.copy() - ret = CollectionState(self, allow_partial_entrances) + ret = CollectionState(self) for item in self.itempool: self.worlds[item.player].collect(ret, item) @@ -642,11 +641,10 @@ class CollectionState(): path: Dict[Union[Region, Entrance], PathValue] locations_checked: Set[Location] stale: Dict[int, bool] - allow_partial_entrances: bool additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] - def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): + def __init__(self, parent: MultiWorld): self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} @@ -655,7 +653,6 @@ def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): self.path = {} self.locations_checked = set() self.stale = {player: True for player in parent.get_all_ids()} - self.allow_partial_entrances = allow_partial_entrances for function in self.additional_init_functions: function(self, parent) for items in parent.precollected_items.values(): @@ -682,8 +679,6 @@ def update_reachable_regions(self, player: int): if new_region in reachable_regions: blocked_connections.remove(connection) elif connection.can_reach(self): - if self.allow_partial_entrances and not new_region: - continue assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" reachable_regions.add(new_region) blocked_connections.remove(connection) @@ -706,7 +701,6 @@ def copy(self) -> CollectionState: ret.events = copy.copy(self.events) ret.path = copy.copy(self.path) ret.locations_checked = copy.copy(self.locations_checked) - ret.allow_partial_entrances = self.allow_partial_entrances for function in self.additional_copy_functions: ret = function(self, ret) return ret @@ -806,33 +800,24 @@ def remove(self, item: Item): class Entrance: - class EntranceType(IntEnum): - ONE_WAY = 1 - TWO_WAY = 2 - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False player: int name: str parent_region: Optional[Region] connected_region: Optional[Region] = None - er_group: str - er_type: EntranceType # LttP specific, TODO: should make a LttPEntrance addresses = None target = None - def __init__(self, player: int, name: str = "", parent: Region = None, - er_group: str = "Default", er_type: EntranceType = EntranceType.ONE_WAY): + def __init__(self, player: int, name: str = '', parent: Region = None): self.name = name self.parent_region = parent self.player = player - self.er_group = er_group - self.er_type = er_type def can_reach(self, state: CollectionState) -> bool: if self.parent_region.can_reach(state) and self.access_rule(state): - if not self.hide_path and self not in state.path: + if not self.hide_path and not self in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) return True @@ -844,34 +829,6 @@ def connect(self, region: Region, addresses: Any = None, target: Any = None) -> self.addresses = addresses region.entrances.append(self) - def is_valid_source_transition(self, state: "ERPlacementState") -> bool: - """ - Determines whether this is a valid source transition, that is, whether the entrance - randomizer is allowed to pair it to place any other regions. By default, this is the - same as a reachability check, but can be modified by Entrance implementations to add - other restrictions based on the placement state. - - :param state: The current (partial) state of the ongoing entrance randomization - """ - return self.can_reach(state.collection_state) - - def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: - """ - Determines whether a given Entrance is a valid target transition, that is, whether - the entrance randomizer is allowed to pair this Entrance to that Entrance. By default, - only allows connection between entrances of the same type (one ways only go to one ways, - two ways always go to two ways) and prevents connecting an exit to itself in coupled mode. - - Generally it is a good idea use call super().can_connect_to as one condition in any custom - implementations unless you specifically want to avoid the above behaviors. - - :param other: The proposed Entrance to connect to - :param state: The current (partial) state of the ongoing entrance randomization - """ - # the implementation of coupled causes issues for self-loops since the reverse entrance will be the - # same as the forward entrance. In uncoupled they are ok. - return self.er_type == other.er_type and (not state.coupled or self.name != other.name) - def __repr__(self): return self.__str__() @@ -1020,16 +977,6 @@ def create_exit(self, name: str) -> Entrance: self.exits.append(exit_) return exit_ - def create_er_target(self, name: str) -> Entrance: - """ - Creates and returns an Entrance object as an entrance to this region - - :param name: name of the Entrance being created - """ - entrance = self.entrance_type(self.player, name) - entrance.connect(self) - return entrance - def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None: """ diff --git a/CommonClient.py b/CommonClient.py index 038a856a3287..736cf4922f40 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -885,8 +885,7 @@ def get_base_parser(description: typing.Optional[str] = None): return parser -def run_as_textclient(*args): - logger.info(args) +def run_as_textclient(): class TextContext(CommonContext): # Text Mode to use !hint and such with games that have no text entry tags = CommonContext.tags | {"TextOnly"} diff --git a/EntranceRando.py b/EntranceRando.py deleted file mode 100644 index f7749dff6b80..000000000000 --- a/EntranceRando.py +++ /dev/null @@ -1,363 +0,0 @@ -import itertools -import logging -import random -import time -from collections import deque -from typing import Callable, Dict, Iterable, List, Tuple, Union, Set - -from BaseClasses import CollectionState, Entrance, Region -from worlds.AutoWorld import World - - -class EntranceRandomizationError(RuntimeError): - pass - - -class EntranceLookup: - class GroupLookup: - _lookup: Dict[str, List[Entrance]] - - def __init__(self): - self._lookup = {} - - def __bool__(self): - return bool(self._lookup) - - def __getitem__(self, item: str) -> List[Entrance]: - return self._lookup.get(item, []) - - def __iter__(self): - return itertools.chain.from_iterable(self._lookup.values()) - - def __str__(self): - return str(self._lookup) - - def __repr__(self): - return self.__str__() - - def add(self, entrance: Entrance) -> None: - self._lookup.setdefault(entrance.er_group, []).append(entrance) - - def remove(self, entrance: Entrance) -> None: - group = self._lookup.get(entrance.er_group, []) - group.remove(entrance) - if not group: - del self._lookup[entrance.er_group] - - dead_ends: GroupLookup - others: GroupLookup - _random: random.Random - _leads_to_exits_cache: Dict[Entrance, bool] - - def __init__(self, rng: random.Random): - self.dead_ends = EntranceLookup.GroupLookup() - self.others = EntranceLookup.GroupLookup() - self._random = rng - self._leads_to_exits_cache = {} - - def _can_lead_to_randomizable_exits(self, entrance: Entrance): - """ - Checks whether an entrance is able to lead to another randomizable exit - with some combination of items - - :param entrance: A randomizable (no parent) region entrance - """ - # we've seen this, return cached result - if entrance in self._leads_to_exits_cache: - return self._leads_to_exits_cache[entrance] - - visited = set() - q = deque() - q.append(entrance.connected_region) - - while q: - region = q.popleft() - visited.add(region) - - for exit_ in region.exits: - # randomizable and not the reverse of the start entrance - if not exit_.connected_region and exit_.name != entrance.name: - self._leads_to_exits_cache[entrance] = True - return True - elif exit_.connected_region and exit_.connected_region not in visited: - q.append(exit_.connected_region) - - self._leads_to_exits_cache[entrance] = False - return False - - def add(self, entrance: Entrance) -> None: - lookup = self.others if self._can_lead_to_randomizable_exits(entrance) else self.dead_ends - lookup.add(entrance) - - def remove(self, entrance: Entrance) -> None: - lookup = self.others if self._can_lead_to_randomizable_exits(entrance) else self.dead_ends - lookup.remove(entrance) - - def get_targets( - self, - groups: Iterable[str], - dead_end: bool, - preserve_group_order: bool - ) -> Iterable[Entrance]: - - lookup = self.dead_ends if dead_end else self.others - if preserve_group_order: - for group in groups: - self._random.shuffle(lookup[group]) - ret = [entrance for group in groups for entrance in lookup[group]] - else: - ret = [entrance for group in groups for entrance in lookup[group]] - self._random.shuffle(ret) - return ret - - -class ERPlacementState: - placements: List[Entrance] - pairings: List[Tuple[str, str]] - world: World - collection_state: CollectionState - coupled: bool - - def __init__(self, world: World, coupled: bool): - self.placements = [] - self.pairings = [] - self.world = world - self.collection_state = world.multiworld.get_all_state(False, True) - self.coupled = coupled - - @property - def placed_regions(self) -> Set[Region]: - return self.collection_state.reachable_regions[self.world.player] - - def find_placeable_exits(self) -> List[Entrance]: - blocked_connections = self.collection_state.blocked_connections[self.world.player] - blocked_connections = sorted(blocked_connections, key=lambda x: x.name) - placeable_randomized_exits = [connection for connection in blocked_connections - if not connection.connected_region - and connection.is_valid_source_transition(self)] - self.world.random.shuffle(placeable_randomized_exits) - return placeable_randomized_exits - - def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: - target_region = target_entrance.connected_region - - target_region.entrances.remove(target_entrance) - source_exit.connect(target_region) - - self.collection_state.stale[self.world.player] = True - self.placements.append(source_exit) - self.pairings.append((source_exit.name, target_entrance.name)) - - def connect( - self, - source_exit: Entrance, - target_entrance: Entrance - ) -> Union[Tuple[Entrance], Tuple[Entrance, Entrance]]: - """ - Connects a source exit to a target entrance in the graph, accounting for coupling - - :returns: The dummy entrance(s) which were removed from the graph - """ - source_region = source_exit.parent_region - target_region = target_entrance.connected_region - - self._connect_one_way(source_exit, target_entrance) - # if we're doing coupled randomization place the reverse transition as well. - if self.coupled and source_exit.er_type == Entrance.EntranceType.TWO_WAY: - for reverse_entrance in source_region.entrances: - if reverse_entrance.name == source_exit.name: - if reverse_entrance.parent_region: - raise EntranceRandomizationError( - f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " - f"because the reverse entrance is already parented to " - f"{reverse_entrance.parent_region.name}.") - break - else: - raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in " - f"{source_exit.parent_region.name}") - for reverse_exit in target_region.exits: - if reverse_exit.name == target_entrance.name: - if reverse_exit.connected_region: - raise EntranceRandomizationError( - f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " - f"because the reverse exit is already connected to " - f"{reverse_exit.connected_region.name}.") - break - else: - raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit " - f"in {target_region.name}.") - self._connect_one_way(reverse_exit, reverse_entrance) - return target_entrance, reverse_entrance - return target_entrance, - - -def disconnect_entrances_for_randomization( - world: World, - entrances_to_split: Iterable[Union[str, Entrance]] -) -> None: - """ - Given entrances in a "vanilla" region graph, splits those entrances in such a way that will prepare them - for randomization in randomize_entrances. Specifically that means the entrance will be disconnected from its - connected region and a corresponding ER target will be created in a way that can respect coupled randomization - requirements for 2 way transitions. - - Preconditions: - 1. Every provided entrance has both a parent and child region (ie, it is fully connected) - 2. Every provided entrance has already been labeled with the appropriate entrance type - - :param world: The world owning the entrances - :param entrances_to_split: The entrances which will be disconnected in preparation for randomization. - Can be Entrance objects, names of entrances, or any mix of the 2 - """ - - for entrance in entrances_to_split: - if isinstance(entrance, str): - entrance = world.multiworld.get_entrance(entrance, world.player) - child_region = entrance.connected_region - parent_region = entrance.parent_region - - # disconnect the edge - child_region.entrances.remove(entrance) - entrance.connected_region = None - - # create the needed ER target - if entrance.er_type == Entrance.EntranceType.TWO_WAY: - # for 2-ways, create a target in the parent region with a matching name to support coupling. - # targets in the child region will be created when the other direction edge is disconnected - target = parent_region.create_er_target(entrance.name) - else: - # for 1-ways, the child region needs a target and coupling/naming is not a concern - target = child_region.create_er_target(child_region.name) - target.er_type = entrance.er_type - - -def randomize_entrances( - world: World, - regions: Iterable[Region], - coupled: bool, - get_target_groups: Callable[[str], List[str]], - preserve_group_order: bool = False -) -> ERPlacementState: - """ - Randomizes Entrances for a single world in the multiworld. - - Depending on how your world is configured, this may be called as early as create_regions or - need to be called as late as pre_fill. In general, earlier is better; the best time to - randomize entrances is as soon as the preconditions are fulfilled. - - Preconditions: - 1. All of your Regions and all of their exits have been created. - 2. Placeholder entrances have been created as the targets of randomization - (each exit will be randomly paired to an entrance). There are 2 primary ways to do this: - * Pre-place your unrandomized region graph, then use disconnect_entrances_for_randomization - to prepare them, or - * Manually prepare your entrances for randomization. Exits to be randomized should be created - and left without a connected region. There should be an equal number of matching dummy - entrances of each entrance type in the region graph which do not have a parent; these can - easily be created with region.create_er_target(). If you plan to use coupled randomization, the names - of these placeholder entrances matter and should exactly match the name of the corresponding exit in - that region. For example, given regions R1 and R2 connected R1 --[E1]-> R2 and R2 --[E2]-> R1 when - unrandomized, then the expected inputs to coupled ER would be the following (the names of the ER targets - for one-way transitions do not matter): - * R1 --[E1]-> None - * None --[E1]-> R1 - * R2 --[E2]-> None - * None --[E2]-> R2 - 3. All entrances and exits have been correctly labeled as 1 way or 2 way. - 4. Your Menu region is connected to your starting region. - 5. All the region connections you don't want to randomize are connected; usually this - is connecting regions within a "scene" but may also include plando'd transitions. - 6. Access rules are set on all relevant region exits. - * Access rules are used to conservatively prevent cases where, given a switch in region R_s - and the gate that it opens being the exit E_g to region R_g, the only way to access R_s - is through a connection R_g --(E_g)-> R_s, thus making R_s inaccessible. If you encode - this kind of cross-region dependency through events or indirect connections, those must - be placed/registered before calling this function if you want them to be respected. - * If you set access rules that contain items other than events, those items must be added to - the multiworld item pool before randomizing entrances. - - Post-conditions: - 1. All randomizable Entrances will be connected - 2. All placeholder entrances to regions will have been removed. - - :param world: Your World instance - :param regions: Regions with no connected entrances that you would like to be randomly connected - :param coupled: Whether connected entrances should be coupled to go in both directions - :param get_target_groups: Method to call that returns the groups that a specific group type is allowed to connect to - :param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups - """ - start_time = time.perf_counter() - er_state = ERPlacementState(world, coupled) - entrance_lookup = EntranceLookup(world.random) - - def do_placement(source_exit: Entrance, target_entrance: Entrance): - removed_entrances = er_state.connect(source_exit, target_entrance) - # remove the placed targets from consideration - for entrance in removed_entrances: - entrance_lookup.remove(entrance) - # propagate new connections - er_state.collection_state.update_reachable_regions(world.player) - - def find_pairing(dead_end: bool, require_new_regions: bool) -> bool: - placeable_exits = er_state.find_placeable_exits() - for source_exit in placeable_exits: - target_groups = get_target_groups(source_exit.er_group) - # anything can connect to the default group - if people don't like it the fix is to - # assign a non-default group - if "Default" not in target_groups: - target_groups.append("Default") - for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): - # requiring a new region is a proxy for enforcing new entrances are added, thus growing the search - # space. this is not quite a full fidelity conversion, but doesn't seem to cause issues enough - # to do more complex checks - region_requirement_satisfied = (not require_new_regions - or target_entrance.connected_region not in er_state.placed_regions) - if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, er_state): - do_placement(source_exit, target_entrance) - return True - else: - # no source exits had any valid target so this stage is deadlocked. swap may be implemented if early - # deadlocking is a frequent issue. - lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others - - # if we're in a stage where we're trying to get to new regions, we could also enter this - # branch in a success state (when all regions of the preferred type have been placed, but there are still - # additional unplaced entrances into those regions) - if require_new_regions: - if all(e.connected_region in er_state.placed_regions for e in lookup): - return False - - raise EntranceRandomizationError( - f"None of the available entrances are valid targets for the available exits.\n" - f"Available entrances: {lookup}\n" - f"Available exits: {placeable_exits}") - - for region in sorted(regions, key=lambda r: r.name): - for entrance in region.entrances: - if not entrance.parent_region: - entrance_lookup.add(entrance) - - # place the menu region and connected start region(s) - er_state.collection_state.update_reachable_regions(world.player) - - # stage 1 - try to place all the non-dead-end entrances - while entrance_lookup.others: - if not find_pairing(False, True): - break - # stage 2 - try to place all the dead-end entrances - while entrance_lookup.dead_ends: - if not find_pairing(True, True): - break - # stage 3 - connect any dangling entrances that remain - while entrance_lookup.others: - find_pairing(False, False) - # stage 4 - last chance for dead ends - while entrance_lookup.dead_ends: - find_pairing(True, False) - - running_time = time.perf_counter() - start_time - if running_time > 1.0: - logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}," - f"named {world.multiworld.player_name[world.player]}") - - return er_state diff --git a/Launcher.py b/Launcher.py index 06b131c029cb..9e184bf1088d 100644 --- a/Launcher.py +++ b/Launcher.py @@ -16,11 +16,10 @@ import shlex import subprocess import sys -import urllib.parse import webbrowser from os.path import isfile from shutil import which -from typing import Sequence, Tuple, Union, Optional +from typing import Sequence, Union, Optional import Utils import settings @@ -108,24 +107,9 @@ def update_settings(): ]) -def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: +def identify(path: Union[None, str]): if path is None: return None, None - if path.startswith("archipelago://"): - logging.info("found uri") - queries = urllib.parse.parse_qs(path) - if "game" in queries: - game = urllib.parse.parse_qs(path)["game"][0] - else: # TODO around 0.5.0 - this is for pre this change webhost uri's - game = "Archipelago" - logging.info(game) - for component in components: - if component.supports_uri and component.game_name == game: - return path, component - elif component.display_name == "Text Client": - # fallback - text_client_component = component - return path, text_client_component for component in components: if component.handles_file(path): return path, component @@ -269,15 +253,6 @@ def run_component(component: Component, *args): logging.warning(f"Component {component} does not appear to be executable.") -def find_component(game: str) -> Component: - for component in components: - if component.game_name and component.game_name == game: - return component - elif component.display_name == "Text Client": - text_client_component = component - return text_client_component - - def main(args: Optional[Union[argparse.Namespace, dict]] = None): if isinstance(args, argparse.Namespace): args = {k: v for k, v in args._get_kwargs()} @@ -293,12 +268,11 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): if not component: logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") - if args["update_settings"]: update_settings() - if "file" in args: + if 'file' in args: run_component(args["component"], args["file"], *args["args"]) - elif "component" in args: + elif 'component' in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: run_gui() diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html index 7f038af59441..9cb48009a427 100644 --- a/WebHostLib/templates/macros.html +++ b/WebHostLib/templates/macros.html @@ -22,7 +22,7 @@ {% for patch in room.seed.slots|list|sort(attribute="player_id") %} {{ patch.player_id }} - {{ patch.player_name }} + {{ patch.player_name }} {{ patch.game }} {% if patch.data %} diff --git a/inno_setup.iss b/inno_setup.iss index 3d0aaa62adb2..b122cdc00b18 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -183,8 +183,8 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{ Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; -Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; -Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; +Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; +Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; [Code] // See: https://stackoverflow.com/a/51614652/2287576 diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 2a49f31a39ab..03c89b75ff11 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -23,13 +23,10 @@ class Component: cli: bool func: Optional[Callable] file_identifier: Optional[Callable[[str], bool]] - game_name: Optional[str] - supports_uri: Optional[bool] def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None, cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None, - func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None, - game_name: Optional[str] = None, supports_uri: Optional[bool] = False): + func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None): self.display_name = display_name self.script_name = script_name self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None @@ -45,8 +42,6 @@ def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_ Type.ADJUSTER if "Adjuster" in display_name else Type.MISC) self.func = func self.file_identifier = file_identifier - self.game_name = game_name - self.supports_uri = supports_uri def handles_file(self, path: str): return self.file_identifier(path) if self.file_identifier else False @@ -77,9 +72,9 @@ def __call__(self, path: str): return False -def launch_textclient(*args): +def launch_textclient(): import CommonClient - launch_subprocess(CommonClient.run_as_textclient(*args), name="TextClient") + launch_subprocess(CommonClient.run_as_textclient, name="TextClient") components: List[Component] = [ diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index c7b41d2887e9..36b0425cca6f 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -10,7 +10,7 @@ from .client_setup import launch_game from .connections import CONNECTIONS, RANDOMIZED_CONNECTIONS, TRANSITIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -from .entrances import shuffle_entrances +# from .entrances import shuffle_entrances from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS @@ -19,7 +19,7 @@ from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation components.append( - Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True) + Component("The Messenger", component_type=Type.CLIENT, func=launch_game)#, game_name="The Messenger", supports_uri=True) ) @@ -81,7 +81,7 @@ class MessengerWorld(World): "Phobekin": set(PHOBEKINS), } - required_client_version = (0, 4, 2) + required_client_version = (0, 4, 3) web = MessengerWeb() @@ -216,8 +216,8 @@ def set_rules(self) -> None: disconnect_portals(self) shuffle_portals(self) - if self.options.shuffle_transitions: - shuffle_entrances(self) + # if self.options.shuffle_transitions: + # shuffle_entrances(self) def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: @@ -236,48 +236,48 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: for portal, output in portal_info: spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player) - if self.options.shuffle_transitions: - for transition in self.transitions: - if (transition.er_type == Entrance.EntranceType.TWO_WAY and - (transition.connected_region.name, "both", self.player) in spoiler.entrances): - continue - spoiler.set_entrance( - transition.name if "->" not in transition.name else transition.parent_region.name, - transition.connected_region.name, - "both" if transition.er_type == Entrance.EntranceType.TWO_WAY - and self.options.shuffle_transitions == ShuffleTransitions.option_coupled - else "", - self.player) - - def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: - if not self.options.shuffle_transitions: - return - - hint_data.update({self.player: {}}) - - all_state = self.multiworld.get_all_state(True) - # sometimes some of my regions aren't in path for some reason? - all_state.update_reachable_regions(self.player) - paths = all_state.path - start = self.multiworld.get_region("Tower HQ", self.player) - start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}] - transition_names = [transition.name for transition in self.transitions] + start_connections - for loc in self.multiworld.get_locations(self.player): - if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"} - or loc.address is None): - continue - path_to_loc = [] - name, connection = paths[loc.parent_region] - while connection != ("Menu", None): - name, connection = connection - if name in transition_names: - path_to_loc.append(name) - - text = "" - for transition in reversed(path_to_loc): - text += f"{transition} => " - text = text.rstrip("=> ") - hint_data[self.player][loc.address] = text + # if self.options.shuffle_transitions: + # for transition in self.transitions: + # if (transition.er_type == Entrance.EntranceType.TWO_WAY and + # (transition.connected_region.name, "both", self.player) in spoiler.entrances): + # continue + # spoiler.set_entrance( + # transition.name if "->" not in transition.name else transition.parent_region.name, + # transition.connected_region.name, + # "both" if transition.er_type == Entrance.EntranceType.TWO_WAY + # and self.options.shuffle_transitions == ShuffleTransitions.option_coupled + # else "", + # self.player) + + # def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: + # if not self.options.shuffle_transitions: + # return + # + # hint_data.update({self.player: {}}) + # + # all_state = self.multiworld.get_all_state(True) + # # sometimes some of my regions aren't in path for some reason? + # all_state.update_reachable_regions(self.player) + # paths = all_state.path + # start = self.multiworld.get_region("Tower HQ", self.player) + # start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}] + # transition_names = [transition.name for transition in self.transitions] + start_connections + # for loc in self.multiworld.get_locations(self.player): + # if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"} + # or loc.address is None): + # continue + # path_to_loc = [] + # name, connection = paths[loc.parent_region] + # while connection != ("Menu", None): + # name, connection = connection + # if name in transition_names: + # path_to_loc.append(name) + # + # text = "" + # for transition in reversed(path_to_loc): + # text += f"{transition} => " + # text = text.rstrip("=> ") + # hint_data[self.player][loc.address] = text def fill_slot_data(self) -> Dict[str, Any]: slot_data = { @@ -320,7 +320,7 @@ def get_item_classification(self, name: str) -> ItemClassification: self.total_shards += count return ItemClassification.progression_skip_balancing if count else ItemClassification.filler - if name == "Windmill Shuriken": + if name == "Windmill Shuriken" and getattr(self, "multiworld", None) is not None: return ItemClassification.progression if self.options.logic_level else ItemClassification.filler if name == "Power Seal": diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 73752c53637f..8af23bd38aad 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -22,7 +22,7 @@ def launch_game(url: Optional[str] = None) -> None: def courier_installed() -> bool: """Check if Courier is installed""" - return os.path.exists(os.path.join(folder, "miniinstaller-log.txt")) + return os.path.exists(os.path.join(folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.Courier.mm.dll")) def mod_installed() -> bool: """Check if the mod is installed""" @@ -49,7 +49,8 @@ def install_courier() -> None: with urllib.request.urlopen(latest_download) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: - zf.extractall(folder) + for member in zf.infolist(): + zf.extract(member, path=folder) working_directory = os.getcwd() os.chdir(folder) @@ -59,12 +60,13 @@ def install_courier() -> None: if not mono_exe: # download and use mono kickstart # this allows steam deck support - mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/main.zip" + mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/master.zip" target = os.path.join(folder, "monoKickstart") with urllib.request.urlopen(mono_kick_url) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: os.makedirs(target, exist_ok=True) - zf.extractall(target) + for member in zf.infolist(): + zf.extract(member, path=target) installer = subprocess.Popen([os.path.join(target, "precompiled"), os.path.join(folder, "MiniInstaller.exe")], shell=False) else: @@ -87,60 +89,30 @@ def install_courier() -> None: def install_mod() -> None: """Installs latest version of the mod""" - # TODO: add /latest before actual PR since i want pre-releases for now - get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" - assets = request_data(get_url)[0]["assets"] - for asset in assets: - if "TheMessengerRandomizerAP" in asset["name"]: - release_url = asset["browser_download_url"] - break - else: - messagebox("Failure!", "something went wrong while trying to get latest mod version") - logging.error(assets) - return + get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" + release_url = request_data(get_url)["assets"]["browser_download_url"] + mod_folder = os.path.join(folder, "Mods", "TheMessengerRandomizerAP") + os.makedirs(mod_folder, exist_ok=True) with urllib.request.urlopen(release_url) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: - zf.extractall(folder) + for member in zf.infolist(): + zf.extract(member, path=mod_folder) messagebox("Success!", "Latest mod successfully installed!") def available_mod_update() -> bool: """Check if there's an available update""" - get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases" - assets = request_data(get_url)[0]["assets"] - # TODO simplify once we're done with 0.13.0 alpha - for asset in assets: - if "TheMessengerRandomizerAP" in asset["name"]: - if asset["label"]: - latest_version = asset["label"] - break - names = asset["name"].strip(".zip").split("-") - if len(names) > 2: - if names[-1].isnumeric(): - latest_version = names[-1] - break - latest_version = 1 - break - latest_version = names[1] - break - else: - return False + get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" + latest_version: str = request_data(get_url)["tag_name"].lstrip("v") toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") with open(toml_path, "r") as f: installed_version = f.read().splitlines()[1].strip("version = \"") logging.info(f"Installed version: {installed_version}. Latest version: {latest_version}") - if not installed_version.isnumeric(): - if installed_version[-1].isnumeric(): - installed_version = installed_version[-1] - else: - installed_version = 1 - return int(latest_version) > int(installed_version) - elif int(latest_version) >= 1: - return True - return tuplize_version(latest_version) > tuplize_version(installed_version) + # one of the alpha builds + return not latest_version.isnumeric() or tuplize_version(latest_version) > tuplize_version(installed_version) from . import MessengerWorld folder = os.path.dirname(MessengerWorld.settings.game_path) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 48489cb91603..8520f7378a9b 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -189,7 +189,7 @@ class MessengerOptions(PerGameCommonOptions): early_meditation: EarlyMed available_portals: AvailablePortals shuffle_portals: ShufflePortals - shuffle_transitions: ShuffleTransitions + # shuffle_transitions: ShuffleTransitions goal: Goal music_box: MusicBox notes_needed: NotesNeeded diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index df4f27099ef7..de9aa28aabc9 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -255,8 +255,8 @@ def disconnect_portals(world: "MessengerWorld") -> None: def validate_portals(world: "MessengerWorld") -> bool: - if world.options.shuffle_transitions: - return True + # if world.options.shuffle_transitions: + # return True new_state = CollectionState(world.multiworld) new_state.update_reachable_regions(world.player) reachable_locs = 0 From 33c1af5689e46a49a8c894a99e2a1bb52baff182 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 20:59:17 -0600 Subject: [PATCH 146/163] missed a spot --- worlds/messenger/client_setup.py | 4 ++-- worlds/messenger/subclasses.py | 31 ++++++++++++++++--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 8af23bd38aad..6d91926b3eed 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -90,9 +90,9 @@ def install_courier() -> None: def install_mod() -> None: """Installs latest version of the mod""" get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" - release_url = request_data(get_url)["assets"]["browser_download_url"] + release_url = request_data(get_url)["assets"][0]["browser_download_url"] - mod_folder = os.path.join(folder, "Mods", "TheMessengerRandomizerAP") + mod_folder = os.path.join(folder, "Mods") os.makedirs(mod_folder, exist_ok=True) with urllib.request.urlopen(release_url) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index ecd37e1dbe19..3572831a7200 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -12,21 +12,22 @@ class MessengerEntrance(Entrance): world: Optional["MessengerWorld"] = None - def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: - from . import MessengerWorld - world = getattr(self, "world", None) - if not world: - return super().can_connect_to(other, state) - assert isinstance(world, MessengerWorld) - # arbitrary minimum number - if world.reachable_locs >= 5: - return super().can_connect_to(other, state) - empty_state = CollectionState(world.multiworld, True) - self.connected_region = other.connected_region - empty_state.update_reachable_regions(world.player) - world.reachable_locs = sum(loc.can_reach(empty_state) for loc in world.multiworld.get_locations(world.player)) - self.connected_region = None - return world.reachable_locs >= 5 and super().can_connect_to(other, state) + # def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: + # from . import MessengerWorld + # world = getattr(self, "world", None) + # if not world: + # return super().can_connect_to(other, state) + # assert isinstance(world, MessengerWorld) + # # arbitrary minimum number + # if world.reachable_locs >= 5: + # return super().can_connect_to(other, state) + # empty_state = CollectionState(world.multiworld, True) + # self.connected_region = other.connected_region + # empty_state.update_reachable_regions(world.player) + # world.reachable_locs = sum(loc.can_reach(empty_state) for loc in world.multiworld.get_locations(world.player)) + # self.connected_region = None + # return world.reachable_locs >= 5 and super().can_connect_to(other, state) + class MessengerRegion(Region): parent: str From 95cfb6748a7883c337bfb3652062e623f2a3344b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 21:16:34 -0600 Subject: [PATCH 147/163] cleanup monokickstart --- worlds/messenger/client_setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 6d91926b3eed..4ca269c315e4 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -62,13 +62,14 @@ def install_courier() -> None: # this allows steam deck support mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/master.zip" target = os.path.join(folder, "monoKickstart") + os.makedirs(target, exist_ok=True) with urllib.request.urlopen(mono_kick_url) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: - os.makedirs(target, exist_ok=True) for member in zf.infolist(): zf.extract(member, path=target) installer = subprocess.Popen([os.path.join(target, "precompiled"), os.path.join(folder, "MiniInstaller.exe")], shell=False) + os.remove(target) else: installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) else: From 94dec1ddaaa37d00ce1a074c9c0ebc1cc133e658 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 21:16:42 -0600 Subject: [PATCH 148/163] add location_name_groups --- worlds/messenger/__init__.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 36b0425cca6f..b46544567ac7 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -80,6 +80,36 @@ class MessengerWorld(World): "Phobe": set(PHOBEKINS), "Phobekin": set(PHOBEKINS), } + location_name_groups = { + "Notes": { + "Autumn Hills - Key of Hope", + "Searing Crags - Key of Strength", + "Underworld - Key of Chaos", + "Sunken Shrine - Key of Love", + "Elemental Skylands - Key of Symbiosis", + "Corrupted Future - Key of Courage", + }, + "Keys": { + "Autumn Hills - Key of Hope", + "Searing Crags - Key of Strength", + "Underworld - Key of Chaos", + "Sunken Shrine - Key of Love", + "Elemental Skylands - Key of Symbiosis", + "Corrupted Future - Key of Courage", + }, + "Phobe": { + "Catacombs - Necro", + "Bamboo Creek - Claustro", + "Searing Crags - Pyro", + "Cloud Ruins - Acro", + }, + "Phobekin": { + "Catacombs - Necro", + "Bamboo Creek - Claustro", + "Searing Crags - Pyro", + "Cloud Ruins - Acro", + }, + } required_client_version = (0, 4, 3) From 4768e619cd87b5e7985869c7693084cc9e9c2a28 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 21:16:53 -0600 Subject: [PATCH 149/163] update docs for new setup --- worlds/messenger/docs/en_The Messenger.md | 4 +-- worlds/messenger/docs/setup_en.md | 33 +++++++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md index 374753b487a0..975319cf3032 100644 --- a/worlds/messenger/docs/en_The Messenger.md +++ b/worlds/messenger/docs/en_The Messenger.md @@ -69,8 +69,8 @@ for it. The groups you can use for The Messenger are: * Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the player. This may also cause a softlock. * Text entry menus don't accept controller input -* Opening the shop chest in power seal hunt mode from the tower of time HQ will softlock the game. -* If you are unable to reset file slots, load into a save slot, let the game save, and close it. +* In power seal hunt mode the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the + chest will not work. ## What do I do if I have a problem? diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md index 9617baf3e007..71334022a6b2 100644 --- a/worlds/messenger/docs/setup_en.md +++ b/worlds/messenger/docs/setup_en.md @@ -9,10 +9,18 @@ ## Installation -1. Read the [Game Info Page](/games/The%20Messenger/info/en) for how the game works, caveats and known issues -2. Download and install Courier Mod Loader using the instructions on the release page +### Automated Installation + +1. Download and install the latest [Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest) +2. Launch the Archipelago Launcher (ArchipelagoLauncher.exe) +3. Click on "The Messenger" +4. Follow the prompts + +### Manual Installation + +1. Download and install Courier Mod Loader using the instructions on the release page * [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases) -3. Download and install the randomizer mod +2. Download and install the randomizer mod 1. Download the latest TheMessengerRandomizerAP.zip from [The Messenger Randomizer Mod AP releases page](https://github.com/alwaysintreble/TheMessengerRandomizerModAP/releases) 2. Extract the zip file to `TheMessenger/Mods/` of your game's install location @@ -32,19 +40,17 @@ ## Joining a MultiWorld Game 1. Launch the game -2. Navigate to `Options > Third Party Mod Options` -3. Select `Reset Randomizer File Slots` - * This will set up all of your save slots with new randomizer save files. You can have up to 3 randomizer files at a - time, but must do this step again to start new runs afterward. -4. Enter connection info using the relevant option buttons +2. Navigate to `Options > Archipelago Options` +3. Enter connection info using the relevant option buttons * **The game is limited to alphanumerical characters, `.`, and `-`.** * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the website. * If using a name that cannot be entered in the in game menus, there is a config file (APConfig.toml) in the game directory. When using this, all connection information must be entered in the file. -5. Select the `Connect to Archipelago` button -6. Navigate to save file selection -7. Select a new valid randomizer save +4. Select the `Connect to Archipelago` button +5. Navigate to save file selection +6. Start a new game + * If you're already connected, deleting a save will not disconnect you and is completely safe. ## Continuing a MultiWorld Game @@ -52,6 +58,5 @@ At any point while playing, it is completely safe to quit. Returning to the titl disconnect you from the server. To reconnect to an in progress MultiWorld, simply load the correct save file for that MultiWorld. -If the reconnection fails, the message on screen will state you are disconnected. If this happens, you can return to the -main menu and connect to the server as in [Joining a Multiworld Game](#joining-a-multiworld-game), then load the correct -save file. +If the reconnection fails, the message on screen will state you are disconnected. If this happens, a connect button will +be added to the in game `Archipelago Options` menu. From 892aa40e49bb17a75353f103872bef3737f04d24 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Wed, 14 Feb 2024 21:25:03 -0600 Subject: [PATCH 150/163] client can reconnect on its own now, no need for a button. --- worlds/messenger/docs/setup_en.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md index 71334022a6b2..d986b70f9c98 100644 --- a/worlds/messenger/docs/setup_en.md +++ b/worlds/messenger/docs/setup_en.md @@ -9,6 +9,8 @@ ## Installation +Read changes to the base game on the [Game Info Page](/games/The%20Messenger/info/en) + ### Automated Installation 1. Download and install the latest [Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest) @@ -58,5 +60,5 @@ At any point while playing, it is completely safe to quit. Returning to the titl disconnect you from the server. To reconnect to an in progress MultiWorld, simply load the correct save file for that MultiWorld. -If the reconnection fails, the message on screen will state you are disconnected. If this happens, a connect button will -be added to the in game `Archipelago Options` menu. +If the reconnection fails, the message on screen will state you are disconnected. If this happens, the game will attempt +to reconnect in the background. An option will also be added to the in game menu to change the port, if necessary. From 5c40bbba615743ca3fec5b9c5c644c46a37390b0 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 15 Feb 2024 20:50:53 -0600 Subject: [PATCH 151/163] fix mod download link grabbing the wrong assets --- worlds/messenger/client_setup.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 4ca269c315e4..a74290f2e6a7 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -91,7 +91,17 @@ def install_courier() -> None: def install_mod() -> None: """Installs latest version of the mod""" get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" - release_url = request_data(get_url)["assets"][0]["browser_download_url"] + assets = request_data(get_url)["assets"] + if len(assets) == 1: + release_url = request_data(get_url)["assets"][0]["browser_download_url"] + else: + for asset in assets: + if "TheMessengerRandomizerAP" in asset["name"]: + release_url = asset["browser_download_url"] + break + else: + messagebox("Failure", "Failed to find latest mod download", True) + raise RuntimeError("Failed to install Mod") mod_folder = os.path.join(folder, "Mods") os.makedirs(mod_folder, exist_ok=True) From 4066a6ae67d41ed8341e3db9f7bf37f8d5fb6550 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Fri, 16 Feb 2024 00:30:44 -0600 Subject: [PATCH 152/163] cleanup mod pulling a bit and display version it's trying to update to --- worlds/messenger/client_setup.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index a74290f2e6a7..f32eb84d365d 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -15,6 +15,9 @@ from Utils import is_linux, is_windows, messagebox, tuplize_version +MOD_URL = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" + + def launch_game(url: Optional[str] = None) -> None: """Check the game installation, then launch it""" if not (is_linux or is_windows): @@ -90,10 +93,9 @@ def install_courier() -> None: def install_mod() -> None: """Installs latest version of the mod""" - get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" - assets = request_data(get_url)["assets"] + assets = request_data(MOD_URL)["assets"] if len(assets) == 1: - release_url = request_data(get_url)["assets"][0]["browser_download_url"] + release_url = assets[0]["browser_download_url"] else: for asset in assets: if "TheMessengerRandomizerAP" in asset["name"]: @@ -112,11 +114,9 @@ def install_mod() -> None: messagebox("Success!", "Latest mod successfully installed!") - def available_mod_update() -> bool: + def available_mod_update(latest_version: str) -> bool: """Check if there's an available update""" - get_url = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" - latest_version: str = request_data(get_url)["tag_name"].lstrip("v") - + latest_version = latest_version.lstrip("v") toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") with open(toml_path, "r") as f: installed_version = f.read().splitlines()[1].strip("version = \"") @@ -142,12 +142,15 @@ def available_mod_update() -> bool: logging.info("Installing Mod") install_mod() else: - if available_mod_update(): + latest = request_data(MOD_URL)["tag_name"] + if available_mod_update(latest): should_update = askyesnocancel("Update Mod", - "Old mod version detected. Would you like to update now?") + f"New mod version detected. Would you like to update to {latest} now?") if should_update: logging.info("Updating mod") install_mod() + elif should_update is None: + return if is_linux: if url: open_file(f"steam://rungameid/764790//{url}/") From 228d09d161f18c0275830ad08ecc05fff350fdc1 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 26 Feb 2024 08:30:57 -0600 Subject: [PATCH 153/163] plando support --- worlds/messenger/__init__.py | 19 +++++++-- worlds/messenger/entrances.py | 46 +++++++++++++++++++++- worlds/messenger/portals.py | 72 +++++++++++++++++++++-------------- 3 files changed, 104 insertions(+), 33 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index b46544567ac7..31ad1f70ff5e 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -12,7 +12,7 @@ from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS # from .entrances import shuffle_entrances from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions -from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals +from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals, validate_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices @@ -123,6 +123,7 @@ class MessengerWorld(World): figurine_prices: Dict[str, int] _filler_items: List[str] starting_portals: List[str] + plando_portals: List[str] spoiler_portal_mapping: Dict[str, str] portal_mapping: List[int] transitions: List[Entrance] @@ -145,6 +146,7 @@ def generate_early(self) -> None: self.starting_portals = [f"{portal} Portal" for portal in starting_portals[:3] + self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)] + self.plando_portals = [] self.portal_mapping = [] self.spoiler_portal_mapping = {} self.transitions = [] @@ -242,9 +244,18 @@ def set_rules(self) -> None: add_closed_portal_reqs(self) # i need portal shuffle to happen after rules exist so i can validate it + attempts = 5 if self.options.shuffle_portals: - disconnect_portals(self) - shuffle_portals(self) + self.portal_mapping = [] + self.spoiler_portal_mapping = {} + for _ in range(attempts): + disconnect_portals(self) + shuffle_portals(self) + if validate_portals(self): + break + # failsafe mostly for invalid plandoed portals with no transition shuffle + else: + raise RuntimeError("Unable to generate valid portal output.") # if self.options.shuffle_transitions: # shuffle_entrances(self) @@ -254,7 +265,9 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: spoiler_handle.write(f"\nStarting Portals:\n\n") for portal in self.starting_portals: spoiler_handle.write(f"{portal}\n") + spoiler = self.multiworld.spoiler + if self.options.shuffle_portals: # sort the portals as they appear left to right in-game portal_info = sorted( diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index cb2c16067af0..afc003fdf612 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -1,7 +1,8 @@ from typing import List, TYPE_CHECKING -from BaseClasses import Entrance +from BaseClasses import EntranceType, PlandoOptions, Region from EntranceRando import randomize_entrances +from worlds.generic import PlandoConnection from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS from .options import ShuffleTransitions @@ -9,6 +10,43 @@ from . import MessengerRegion, MessengerWorld +def connect_plando(world: "MessengerWorld", plando_connections: List[PlandoConnection]) -> None: + def disconnect_exit(region: Region) -> None: + # find the disconnected exit and remove references to it + for _exit in region.exits: + if not _exit.connected_region: + break + else: + raise ValueError(f"Unable to find randomized transition for {connection}") + region.exits.remove(_exit) + + def disconnect_entrance(region: Region) -> None: + # find the disconnected entrance and remove references to it + for _entrance in reg2.entrances: + if not _entrance.parent_region: + break + else: + raise ValueError(f"Invalid target region for {connection}") + _entrance.parent_region.entrances.remove(_entrance) + + multiworld = world.multiworld + player = world.player + for connection in plando_connections: + # get the connecting regions + reg1 = multiworld.get_region(connection.entrance, player) + reg2 = multiworld.get_region(connection.exit, player) + + disconnect_exit(reg1) + disconnect_entrance(reg2) + # connect the regions + reg1.connect(reg2) + + if connection.direction == "both": + disconnect_exit(reg2) + disconnect_entrance(reg1) + reg2.connect(reg1) + + def shuffle_entrances(world: "MessengerWorld") -> None: multiworld = world.multiworld player = world.player @@ -43,7 +81,11 @@ def disconnect_entrance() -> None: disconnect_entrance() regions_to_shuffle += [parent_region, child_region] - result = randomize_entrances(world, set(regions_to_shuffle), coupled, lambda group: ["Default"]) + plando = world.multiworld.plando_connections[player] + if plando and world.multiworld.plando_options & PlandoOptions.connections: + connect_plando(world, plando) + + result = randomize_entrances(world, coupled, lambda group: ["Default"]) world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index de9aa28aabc9..2d2898cd32c0 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -1,7 +1,8 @@ -from typing import TYPE_CHECKING +from typing import List, TYPE_CHECKING, Tuple -from BaseClasses import CollectionState +from BaseClasses import CollectionState, PlandoOptions from .options import ShufflePortals +from ..generic import PlandoConnection if TYPE_CHECKING: from . import MessengerWorld @@ -204,23 +205,12 @@ def shuffle_portals(world: "MessengerWorld") -> None: - shuffle_type = world.options.shuffle_portals - shop_points = SHOP_POINTS.copy() - for portal in PORTALS: - shop_points[portal].append(f"{portal} Portal") - if shuffle_type > ShufflePortals.option_shops: - shop_points.update(CHECKPOINTS) - out_to_parent = {checkpoint: parent for parent, checkpoints in shop_points.items() for checkpoint in checkpoints} - available_portals = [val for zone in shop_points.values() for val in zone] - - world.portal_mapping = [] - world.spoiler_portal_mapping = {} - for portal in PORTALS: - warp_point = world.random.choice(available_portals) - parent = out_to_parent[warp_point] - # determine the name of the region of the warp point and save it in our + def create_mapping(in_portal: str, warp: str) -> None: + nonlocal available_portals + parent = out_to_parent[warp] exit_string = f"{parent.strip(' ')} - " - if "Portal" in warp_point: + + if "Portal" in warp: exit_string += "Portal" world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}00")) elif warp_point in SHOP_POINTS[parent]: @@ -229,17 +219,39 @@ def shuffle_portals(world: "MessengerWorld") -> None: else: exit_string += f"{warp_point} Checkpoint" world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}2{CHECKPOINTS[parent].index(warp_point)}")) - world.spoiler_portal_mapping[portal] = exit_string - connect_portal(world, portal, exit_string) - - available_portals.remove(warp_point) + + world.spoiler_portal_mapping[in_portal] = exit_string + connect_portal(world, in_portal, exit_string) + + available_portals.remove(warp) if shuffle_type < ShufflePortals.option_anywhere: - available_portals = [portal for portal in available_portals - if portal not in shop_points[out_to_parent[warp_point]]] + available_portals = [port for port in available_portals if port not in shop_points[parent]] - if not validate_portals(world): - disconnect_portals(world) - shuffle_portals(world) + def handle_planned_portals(plando_connections: List[PlandoConnection]) -> None: + for connection in plando_connections: + if connection.entrance not in PORTALS: + continue + # let it crash here if input is invalid + create_mapping(connection.entrance, connection.exit) + world.plando_portals.append(connection.entrance) + + shuffle_type = world.options.shuffle_portals + shop_points = SHOP_POINTS.copy() + for portal in PORTALS: + shop_points[portal].append(f"{portal} Portal") + if shuffle_type > ShufflePortals.option_shops: + shop_points.update(CHECKPOINTS) + out_to_parent = {checkpoint: parent for parent, checkpoints in shop_points.items() for checkpoint in checkpoints} + available_portals = [val for zone in shop_points.values() for val in zone] + + plando = world.multiworld.plando_connections[world.player] + if plando and world.multiworld.plando_options & PlandoOptions.connections: + handle_planned_portals(plando) + world.multiworld.plando_connections[world.player] = [connection for connection in plando + if connection.entrance not in PORTALS] + for portal in PORTALS: + warp_point = world.random.choice(available_portals) + create_mapping(portal, warp_point) def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> None: @@ -248,10 +260,14 @@ def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> Non def disconnect_portals(world: "MessengerWorld") -> None: - for portal in PORTALS: + for portal in [port for port in PORTALS if port not in world.plando_portals]: entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) entrance.connected_region.entrances.remove(entrance) entrance.connected_region = None + if portal in world.spoiler_portal_mapping: + del world.spoiler_portal_mapping[portal] + if len(world.portal_mapping) > len(world.spoiler_portal_mapping): + world.portal_mapping = world.portal_mapping[:len(world.spoiler_portal_mapping)] def validate_portals(world: "MessengerWorld") -> bool: From 6f9c751a0e6f50d6e08ed71c40680f618016ba57 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 26 Feb 2024 08:38:49 -0600 Subject: [PATCH 154/163] comment out broken steam deck support --- worlds/messenger/__init__.py | 3 +-- worlds/messenger/client_setup.py | 27 +++++++++++++++------------ worlds/messenger/entrances.py | 4 ++-- worlds/messenger/portals.py | 2 +- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 31ad1f70ff5e..50cce72e9f1f 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -181,8 +181,7 @@ def create_items(self) -> None: itempool: List[MessengerItem] = [ self.create_item(item) for item in self.item_name_to_id - if item not in - { + if item not in { "Power Seal", *NOTES, *FIGURINES, *main_movement_items, *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, } and "Time Shard" not in item diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index f32eb84d365d..0c4e992a515d 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -61,18 +61,21 @@ def install_courier() -> None: if is_linux: mono_exe = which("mono") if not mono_exe: - # download and use mono kickstart - # this allows steam deck support - mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/master.zip" - target = os.path.join(folder, "monoKickstart") - os.makedirs(target, exist_ok=True) - with urllib.request.urlopen(mono_kick_url) as download: - with ZipFile(io.BytesIO(download.read()), "r") as zf: - for member in zf.infolist(): - zf.extract(member, path=target) - installer = subprocess.Popen([os.path.join(target, "precompiled"), - os.path.join(folder, "MiniInstaller.exe")], shell=False) - os.remove(target) + # steam deck support but doesn't currently work + messagebox("Failure", "Failed to install Courier", True) + raise RuntimeError("Failed to install Courier") + # # download and use mono kickstart + # # this allows steam deck support + # mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/master.zip" + # target = os.path.join(folder, "monoKickstart") + # os.makedirs(target, exist_ok=True) + # with urllib.request.urlopen(mono_kick_url) as download: + # with ZipFile(io.BytesIO(download.read()), "r") as zf: + # for member in zf.infolist(): + # zf.extract(member, path=target) + # installer = subprocess.Popen([os.path.join(target, "precompiled"), + # os.path.join(folder, "MiniInstaller.exe")], shell=False) + # os.remove(target) else: installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) else: diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index afc003fdf612..0ade874742a2 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -22,12 +22,12 @@ def disconnect_exit(region: Region) -> None: def disconnect_entrance(region: Region) -> None: # find the disconnected entrance and remove references to it - for _entrance in reg2.entrances: + for _entrance in region.entrances: if not _entrance.parent_region: break else: raise ValueError(f"Invalid target region for {connection}") - _entrance.parent_region.entrances.remove(_entrance) + region.entrances.remove(_entrance) multiworld = world.multiworld player = world.player diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 2d2898cd32c0..64438b018400 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -1,4 +1,4 @@ -from typing import List, TYPE_CHECKING, Tuple +from typing import List, TYPE_CHECKING from BaseClasses import CollectionState, PlandoOptions from .options import ShufflePortals From d20b4ad16547778c2a719fb84ccee49b8e54dbc6 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 26 Feb 2024 08:53:45 -0600 Subject: [PATCH 155/163] supports plando --- worlds/messenger/options.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 8520f7378a9b..36abababbbef 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -56,6 +56,7 @@ class ShufflePortals(Choice): """ Whether the portals lead to random places. Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant. + Supports plando. None: Portals will take you where they're supposed to. Shops: Portals can lead to any area except Music Box and Elemental Skylands, with each portal output guaranteed to not overlap with another portal's. Will only put you at a portal or a shop. @@ -73,6 +74,7 @@ class ShufflePortals(Choice): class ShuffleTransitions(Choice): """ Whether the transitions between the levels should be randomized. + Supports plando. None: Level transitions lead where they should. Coupled: Returning through a transition will take you from whence you came. From e6d0eb85b919a59d1b6ba690c3bcfe0e5a8d383b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Mon, 26 Feb 2024 09:26:03 -0600 Subject: [PATCH 156/163] satisfy flake for currently unused file --- worlds/messenger/entrances.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py index 0ade874742a2..df7c6b0eb747 100644 --- a/worlds/messenger/entrances.py +++ b/worlds/messenger/entrances.py @@ -56,9 +56,9 @@ def disconnect_entrance() -> None: child_region.entrances.remove(entrance) entrance.connected_region = None - er_type = Entrance.EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \ - Entrance.EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else Entrance.EntranceType.ONE_WAY - if er_type == Entrance.EntranceType.TWO_WAY: + er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \ + EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY + if er_type == EntranceType.TWO_WAY: mock_entrance = parent_region.create_er_target(entrance.name) else: mock_entrance = child_region.create_er_target(child) From e2174c79c6891fae411c66100845aca7589e61ed Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Thu, 7 Mar 2024 20:21:55 -0600 Subject: [PATCH 157/163] fix the items accessibility test --- worlds/messenger/rules.py | 14 ++++++-------- worlds/messenger/test/test_access.py | 5 +---- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index a4b17f2fbcb3..22e21f31e8ae 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -362,8 +362,8 @@ def set_messenger_rules(self) -> None: if self.world.options.music_box and not self.world.options.limited_movement: add_rule(multiworld.get_entrance("Shrink Down", self.player), self.has_dart) multiworld.completion_condition[self.player] = lambda state: state.has("Do the Thing!", self.player) - # if multiworld.accessibility[self.player]: # not locations accessibility - # set_self_locking_items(self.world, self.player) + if self.world.options.accessibility: # not locations accessibility + set_self_locking_items(self.world, self.player) class MessengerHardRules(MessengerRules): @@ -522,10 +522,8 @@ def set_messenger_rules(self) -> None: def set_self_locking_items(world: "MessengerWorld", player: int) -> None: - multiworld = world.multiworld - # locations where these placements are always valid - allow_self_locking_items(multiworld.get_location("Searing Crags - Key of Strength", player), "Power Thistle") - allow_self_locking_items(multiworld.get_location("Sunken Shrine - Key of Love", player), "Sun Crest", "Moon Crest") - allow_self_locking_items(multiworld.get_location("Corrupted Future - Key of Courage", player), "Demon King Crown") - allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), "Currents Master") + allow_self_locking_items(world.get_location("Searing Crags - Key of Strength"), "Power Thistle") + allow_self_locking_items(world.get_location("Sunken Shrine - Key of Love"), "Sun Crest", "Moon Crest") + allow_self_locking_items(world.get_location("Corrupted Future - Key of Courage"), "Demon King Crown") + allow_self_locking_items(world.get_location("Elemental Skylands Seal - Water"), "Currents Master") diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py index da28f1d19463..016f3b57cdef 100644 --- a/worlds/messenger/test/test_access.py +++ b/worlds/messenger/test/test_access.py @@ -201,12 +201,9 @@ def test_self_locking_items(self) -> None: "Searing Crags - Key of Strength": ["Power Thistle"], "Sunken Shrine - Key of Love": ["Sun Crest", "Moon Crest"], "Corrupted Future - Key of Courage": ["Demon King Crown"], - "Cloud Ruins - Acro": ["Ruxxtin's Amulet"], - "Forlorn Temple - Demon King": PHOBEKINS } - self.multiworld.state = self.multiworld.get_all_state(True) - self.remove_by_name(location_lock_pairs.values()) + self.collect_all_but([item for items in location_lock_pairs.values() for item in items]) for loc in location_lock_pairs: for item_name in location_lock_pairs[loc]: item = self.get_item_by_name(item_name) From d062e78133cc21ee4a315a15aa576aff9edd313a Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 9 Mar 2024 16:32:54 -0600 Subject: [PATCH 158/163] review comments --- worlds/messenger/__init__.py | 28 ++++++++++++----------- worlds/messenger/client_setup.py | 25 ++++++++++---------- worlds/messenger/docs/en_The Messenger.md | 2 +- worlds/messenger/options.py | 6 ++--- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index d6767258a615..181d20d611e3 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -138,7 +138,8 @@ def generate_early(self) -> None: if self.options.logic_level < Logic.option_hard: self.options.logic_level.value = Logic.option_hard - self.multiworld.early_items[self.player]["Meditation"] = self.options.early_meditation.value + if self.options.early_meditation: + self.multiworld.early_items[self.player]["Meditation"] = 1 self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) @@ -160,19 +161,20 @@ def stage_generate_early(cls, multiworld: MultiWorld): def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld # create simple regions - for level in LEVELS: - MessengerRegion(level, self) - # create and connect complex regions that have sub-regions - for region in [MessengerRegion(f"{parent} - {reg_name}", self, parent) - for parent, sub_region in CONNECTIONS.items() - for reg_name in sub_region]: + simple_regions = [MessengerRegion(level, self) for level in LEVELS] + # create complex regions that have sub-regions + complex_regions = [MessengerRegion(f"{parent} - {reg_name}", self, parent) + for parent, sub_region in CONNECTIONS.items() + for reg_name in sub_region] + + for region in complex_regions: region_name = region.name.replace(f"{region.parent} - ", "") connection_data = CONNECTIONS[region.parent][region_name] for exit_region in connection_data: region.connect(self.multiworld.get_region(exit_region, self.player)) + # all regions need to be created before i can do these connections so we create and connect the complex first - for region_name in [level for level in LEVELS if level in REGION_CONNECTIONS]: - region = self.multiworld.get_region(region_name, self.player) + for region in [level for level in simple_regions if level.name in REGION_CONNECTIONS]: region.add_exits(REGION_CONNECTIONS[region.name]) def create_items(self) -> None: @@ -181,10 +183,10 @@ def create_items(self) -> None: itempool: List[MessengerItem] = [ self.create_item(item) for item in self.item_name_to_id - if item not in { - "Power Seal", *NOTES, *FIGURINES, *main_movement_items, - *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, - } and "Time Shard" not in item + if "Time Shard" not in item and item not in { + "Power Seal", *NOTES, *FIGURINES, *main_movement_items, + *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, + } ] if self.options.limited_movement: diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 0c4e992a515d..6a6d9201e23d 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -25,11 +25,11 @@ def launch_game(url: Optional[str] = None) -> None: def courier_installed() -> bool: """Check if Courier is installed""" - return os.path.exists(os.path.join(folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.Courier.mm.dll")) + return os.path.exists(os.path.join(game_folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.Courier.mm.dll")) def mod_installed() -> bool: """Check if the mod is installed""" - return os.path.exists(os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) + return os.path.exists(os.path.join(game_folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) def request_data(request_url: str) -> Any: """Fetches json response from given url""" @@ -53,10 +53,9 @@ def install_courier() -> None: with urllib.request.urlopen(latest_download) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: for member in zf.infolist(): - zf.extract(member, path=folder) + zf.extract(member, path=game_folder) - working_directory = os.getcwd() - os.chdir(folder) + os.chdir(game_folder) # linux handling if is_linux: mono_exe = which("mono") @@ -77,9 +76,9 @@ def install_courier() -> None: # os.path.join(folder, "MiniInstaller.exe")], shell=False) # os.remove(target) else: - installer = subprocess.Popen([mono_exe, os.path.join(folder, "MiniInstaller.exe")], shell=False) + installer = subprocess.Popen([mono_exe, os.path.join(game_folder, "MiniInstaller.exe")], shell=False) else: - installer = subprocess.Popen(os.path.join(folder, "MiniInstaller.exe"), shell=False) + installer = subprocess.Popen(os.path.join(game_folder, "MiniInstaller.exe"), shell=False) failure = installer.wait() if failure: @@ -108,7 +107,7 @@ def install_mod() -> None: messagebox("Failure", "Failed to find latest mod download", True) raise RuntimeError("Failed to install Mod") - mod_folder = os.path.join(folder, "Mods") + mod_folder = os.path.join(game_folder, "Mods") os.makedirs(mod_folder, exist_ok=True) with urllib.request.urlopen(release_url) as download: with ZipFile(io.BytesIO(download.read()), "r") as zf: @@ -120,16 +119,17 @@ def install_mod() -> None: def available_mod_update(latest_version: str) -> bool: """Check if there's an available update""" latest_version = latest_version.lstrip("v") - toml_path = os.path.join(folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") + toml_path = os.path.join(game_folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") with open(toml_path, "r") as f: installed_version = f.read().splitlines()[1].strip("version = \"") logging.info(f"Installed version: {installed_version}. Latest version: {latest_version}") # one of the alpha builds - return not latest_version.isnumeric() or tuplize_version(latest_version) > tuplize_version(installed_version) + return "alpha" in latest_version or tuplize_version(latest_version) > tuplize_version(installed_version) from . import MessengerWorld - folder = os.path.dirname(MessengerWorld.settings.game_path) + game_folder = os.path.dirname(MessengerWorld.settings.game_path) + working_directory = os.getcwd() if not courier_installed(): should_install = askyesnocancel("Install Courier", "No Courier installation detected. Would you like to install now?") @@ -160,8 +160,9 @@ def available_mod_update(latest_version: str) -> bool: else: open_file("steam://rungameid/764790") else: - os.chdir(Path(MessengerWorld.settings.game_path).parent) + os.chdir(game_folder) if url: subprocess.Popen([MessengerWorld.settings.game_path, str(url)]) else: subprocess.Popen(MessengerWorld.settings.game_path) + os.chdir(working_directory) diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md index 975319cf3032..f071ba1c1435 100644 --- a/worlds/messenger/docs/en_The Messenger.md +++ b/worlds/messenger/docs/en_The Messenger.md @@ -69,7 +69,7 @@ for it. The groups you can use for The Messenger are: * Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the player. This may also cause a softlock. * Text entry menus don't accept controller input -* In power seal hunt mode the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the +* In power seal hunt mode, the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the chest will not work. ## What do I do if I have a problem? diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 792d8c5eb3dc..c56ee700438f 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -17,8 +17,8 @@ class Logic(Choice): """ The level of logic to use when determining what locations in your world are accessible. - Normal: can require damage boosts, but otherwise approachable for someone who has beaten the game. - Hard: expects more knowledge and tighter execution. has leashing, normal clips and much tighter d-boosting in logic. + Normal: Can require damage boosts, but otherwise approachable for someone who has beaten the game. + Hard: Expects more knowledge and tighter execution. Has leashing, normal clips and much tighter d-boosting in logic. """ display_name = "Logic Level" option_normal = 0 @@ -45,7 +45,7 @@ class EarlyMed(Toggle): class AvailablePortals(Range): - """Number of portals that are available from the start. Autumn Hills, Howling Grotto, and Glacial Peak are currently always available. If portal outputs are not randomized, Searing Crags will also be available.""" + """Number of portals that are available from the start. Autumn Hills, Howling Grotto, and Glacial Peak are always available. If portal outputs are not randomized, Searing Crags will also be available.""" display_name = "Available Starting Portals" range_start = 3 range_end = 6 From 20ea94be34cc22ad37839ec19881e29a333d73b3 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sat, 9 Mar 2024 16:33:38 -0600 Subject: [PATCH 159/163] add searing crags portal to starting portals when disabled like option says --- worlds/messenger/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 181d20d611e3..f5b3ff421c3e 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -147,6 +147,15 @@ def generate_early(self) -> None: self.starting_portals = [f"{portal} Portal" for portal in starting_portals[:3] + self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)] + # super complicated method for adding searing crags to starting portals if it wasn't chosen + # need to add a check for transition shuffle when that gets added back in + if not self.options.shuffle_portals and "Searing Crags Portal" not in self.starting_portals: + self.starting_portals.append("Searing Crags Portal") + if len(self.starting_portals) > 4: + portals_to_strip = [portal for portal in ["Riviere Turquoise Portal", "Sunken Shrine Portal"] + if portal in self.starting_portals] + self.starting_portals.remove(self.random.choice(portals_to_strip)) + self.plando_portals = [] self.portal_mapping = [] self.spoiler_portal_mapping = {} From 5889384d7d96baf3ef31e1a34538b4e40c52916b Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 10 Mar 2024 11:46:20 -0500 Subject: [PATCH 160/163] address sliver comments --- worlds/messenger/__init__.py | 46 +++++++++++++++----------------- worlds/messenger/client_setup.py | 12 +++------ 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index f5b3ff421c3e..51697f44f59d 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -161,12 +161,6 @@ def generate_early(self) -> None: self.spoiler_portal_mapping = {} self.transitions = [] - @classmethod - def stage_generate_early(cls, multiworld: MultiWorld): - if multiworld.players > 1: - return - cls.generate_output = generate_output - def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld # create simple regions @@ -247,9 +241,11 @@ def set_rules(self) -> None: logic = self.options.logic_level if logic == Logic.option_normal: MessengerRules(self).set_messenger_rules() - else: + elif logic == Logic.option_hard: MessengerHardRules(self).set_messenger_rules() - # else: + else: + raise ValueError(f"Somehow you have a logic option that's currently invalid." + f" {logic} for {self.multiworld.get_player_name(self.player)}") # MessengerOOBRules(self).set_messenger_rules() add_closed_portal_reqs(self) @@ -401,19 +397,21 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: state.prog_items[self.player]["Shards"] -= int(item.name.strip("Time Shard ()")) return change - -def generate_output(world: MessengerWorld, output_directory: str) -> None: - out_path = output_path(world.multiworld.get_out_file_name_base(1) + ".aptm") - if "The Messenger\\Archipelago\\output" not in out_path: - return - import orjson - data = { - "name": world.multiworld.get_player_name(world.player), - "slot_data": world.fill_slot_data(), - "loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]} - for loc in world.multiworld.get_filled_locations() if loc.address}, - } - - output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) - with open(out_path, "wb") as f: - f.write(output) + @classmethod + def stage_generate_output(cls, multiworld: MultiWorld, output_directory: str) -> None: + if multiworld.players > 1: + return + out_path = output_path(multiworld.get_out_file_name_base(1) + ".aptm") + if "The Messenger\\Archipelago\\output" not in out_path: + return + import orjson + data = { + "name": multiworld.get_player_name(1), + "slot_data": multiworld.worlds[1].fill_slot_data(), + "loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]} + for loc in multiworld.get_filled_locations() if loc.address}, + } + + output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) + with open(out_path, "wb") as f: + f.write(output) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 6a6d9201e23d..9fd08e52d899 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -3,7 +3,6 @@ import os.path import subprocess import urllib.request -from pathlib import Path from shutil import which from tkinter.messagebox import askyesnocancel from typing import Any, Optional @@ -12,7 +11,7 @@ import requests -from Utils import is_linux, is_windows, messagebox, tuplize_version +from Utils import is_windows, messagebox, tuplize_version MOD_URL = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" @@ -20,9 +19,6 @@ def launch_game(url: Optional[str] = None) -> None: """Check the game installation, then launch it""" - if not (is_linux or is_windows): - return - def courier_installed() -> bool: """Check if Courier is installed""" return os.path.exists(os.path.join(game_folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.Courier.mm.dll")) @@ -56,8 +52,8 @@ def install_courier() -> None: zf.extract(member, path=game_folder) os.chdir(game_folder) - # linux handling - if is_linux: + # linux and mac handling + if not is_windows: mono_exe = which("mono") if not mono_exe: # steam deck support but doesn't currently work @@ -154,7 +150,7 @@ def available_mod_update(latest_version: str) -> bool: install_mod() elif should_update is None: return - if is_linux: + if not is_windows: if url: open_file(f"steam://rungameid/764790//{url}/") else: From 77f79a625f4cdb824ad55e3dbab9166bac2e7ced Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 10 Mar 2024 11:48:23 -0500 Subject: [PATCH 161/163] rip out currently unused transition shuffle --- worlds/messenger/__init__.py | 47 ---------------- worlds/messenger/entrances.py | 97 ---------------------------------- worlds/messenger/subclasses.py | 16 ------ 3 files changed, 160 deletions(-) delete mode 100644 worlds/messenger/entrances.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 51697f44f59d..5f69e14b9b50 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -10,7 +10,6 @@ from .client_setup import launch_game from .connections import CONNECTIONS, RANDOMIZED_CONNECTIONS, TRANSITIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -# from .entrances import shuffle_entrances from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals, validate_portals from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS @@ -263,9 +262,6 @@ def set_rules(self) -> None: else: raise RuntimeError("Unable to generate valid portal output.") - # if self.options.shuffle_transitions: - # shuffle_entrances(self) - def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: spoiler_handle.write(f"\nStarting Portals:\n\n") @@ -285,49 +281,6 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: for portal, output in portal_info: spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player) - # if self.options.shuffle_transitions: - # for transition in self.transitions: - # if (transition.er_type == Entrance.EntranceType.TWO_WAY and - # (transition.connected_region.name, "both", self.player) in spoiler.entrances): - # continue - # spoiler.set_entrance( - # transition.name if "->" not in transition.name else transition.parent_region.name, - # transition.connected_region.name, - # "both" if transition.er_type == Entrance.EntranceType.TWO_WAY - # and self.options.shuffle_transitions == ShuffleTransitions.option_coupled - # else "", - # self.player) - - # def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: - # if not self.options.shuffle_transitions: - # return - # - # hint_data.update({self.player: {}}) - # - # all_state = self.multiworld.get_all_state(True) - # # sometimes some of my regions aren't in path for some reason? - # all_state.update_reachable_regions(self.player) - # paths = all_state.path - # start = self.multiworld.get_region("Tower HQ", self.player) - # start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}] - # transition_names = [transition.name for transition in self.transitions] + start_connections - # for loc in self.multiworld.get_locations(self.player): - # if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"} - # or loc.address is None): - # continue - # path_to_loc = [] - # name, connection = paths[loc.parent_region] - # while connection != ("Menu", None): - # name, connection = connection - # if name in transition_names: - # path_to_loc.append(name) - # - # text = "" - # for transition in reversed(path_to_loc): - # text += f"{transition} => " - # text = text.rstrip("=> ") - # hint_data[self.player][loc.address] = text - def fill_slot_data(self) -> Dict[str, Any]: slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, diff --git a/worlds/messenger/entrances.py b/worlds/messenger/entrances.py deleted file mode 100644 index df7c6b0eb747..000000000000 --- a/worlds/messenger/entrances.py +++ /dev/null @@ -1,97 +0,0 @@ -from typing import List, TYPE_CHECKING - -from BaseClasses import EntranceType, PlandoOptions, Region -from EntranceRando import randomize_entrances -from worlds.generic import PlandoConnection -from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS -from .options import ShuffleTransitions - -if TYPE_CHECKING: - from . import MessengerRegion, MessengerWorld - - -def connect_plando(world: "MessengerWorld", plando_connections: List[PlandoConnection]) -> None: - def disconnect_exit(region: Region) -> None: - # find the disconnected exit and remove references to it - for _exit in region.exits: - if not _exit.connected_region: - break - else: - raise ValueError(f"Unable to find randomized transition for {connection}") - region.exits.remove(_exit) - - def disconnect_entrance(region: Region) -> None: - # find the disconnected entrance and remove references to it - for _entrance in region.entrances: - if not _entrance.parent_region: - break - else: - raise ValueError(f"Invalid target region for {connection}") - region.entrances.remove(_entrance) - - multiworld = world.multiworld - player = world.player - for connection in plando_connections: - # get the connecting regions - reg1 = multiworld.get_region(connection.entrance, player) - reg2 = multiworld.get_region(connection.exit, player) - - disconnect_exit(reg1) - disconnect_entrance(reg2) - # connect the regions - reg1.connect(reg2) - - if connection.direction == "both": - disconnect_exit(reg2) - disconnect_entrance(reg1) - reg2.connect(reg1) - - -def shuffle_entrances(world: "MessengerWorld") -> None: - multiworld = world.multiworld - player = world.player - coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled - - def disconnect_entrance() -> None: - child_region.entrances.remove(entrance) - entrance.connected_region = None - - er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \ - EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY - if er_type == EntranceType.TWO_WAY: - mock_entrance = parent_region.create_er_target(entrance.name) - else: - mock_entrance = child_region.create_er_target(child) - - entrance.er_type = er_type - mock_entrance.er_type = er_type - - regions_to_shuffle: List[MessengerRegion] = [] - for parent, child in RANDOMIZED_CONNECTIONS.items(): - - if child == "Corrupted Future": - entrance = multiworld.get_entrance("Artificer's Portal", player) - elif child == "Tower of Time - Left": - entrance = multiworld.get_entrance("Artificer's Challenge", player) - else: - entrance = multiworld.get_entrance(f"{parent} -> {child}", player) - parent_region = entrance.parent_region - child_region = entrance.connected_region - entrance.world = world - disconnect_entrance() - regions_to_shuffle += [parent_region, child_region] - - plando = world.multiworld.plando_connections[player] - if plando and world.multiworld.plando_options & PlandoOptions.connections: - connect_plando(world, plando) - - result = randomize_entrances(world, coupled, lambda group: ["Default"]) - - world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) - - for transition in world.transitions: - if "->" not in transition.name: - continue - transition.parent_region.exits.remove(transition) - transition.name = f"{transition.parent_region} -> {transition.connected_region}" - transition.parent_region.exits.append(transition) diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 3572831a7200..b60aeb179feb 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -12,22 +12,6 @@ class MessengerEntrance(Entrance): world: Optional["MessengerWorld"] = None - # def can_connect_to(self, other: Entrance, state: "ERPlacementState") -> bool: - # from . import MessengerWorld - # world = getattr(self, "world", None) - # if not world: - # return super().can_connect_to(other, state) - # assert isinstance(world, MessengerWorld) - # # arbitrary minimum number - # if world.reachable_locs >= 5: - # return super().can_connect_to(other, state) - # empty_state = CollectionState(world.multiworld, True) - # self.connected_region = other.connected_region - # empty_state.update_reachable_regions(world.player) - # world.reachable_locs = sum(loc.can_reach(empty_state) for loc in world.multiworld.get_locations(world.player)) - # self.connected_region = None - # return world.reachable_locs >= 5 and super().can_connect_to(other, state) - class MessengerRegion(Region): parent: str From 6d8b92fb4eee26b553904f1f646233611444a435 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 10 Mar 2024 11:51:28 -0500 Subject: [PATCH 162/163] add aerobatics warrior requirement to fire seal --- worlds/messenger/rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 22e21f31e8ae..50e1fa113d19 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -303,7 +303,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Elemental Skylands Seal - Water": lambda state: self.has_dart(state) and state.has("Currents Master", self.player), "Elemental Skylands Seal - Fire": - lambda state: self.has_dart(state) and self.can_destroy_projectiles(state), + lambda state: self.has_dart(state) and self.can_destroy_projectiles(state) and self.is_aerobatic(state), "Earth Mega Shard": self.has_dart, "Water Mega Shard": From 513beb3825a88b71687233bcc0f6e0e2f5d378a8 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 10 Mar 2024 13:35:52 -0500 Subject: [PATCH 163/163] add comments explaining the output stuff --- worlds/messenger/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 5f69e14b9b50..c40ca02f42f1 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -352,8 +352,11 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: @classmethod def stage_generate_output(cls, multiworld: MultiWorld, output_directory: str) -> None: + # using stage_generate_output because it doesn't increase the logged player count for players without output + # only generate output if there's a single player if multiworld.players > 1: return + # the messenger client calls into AP with specific args, so check the out path matches what the client sends out_path = output_path(multiworld.get_out_file_name_base(1) + ".aptm") if "The Messenger\\Archipelago\\output" not in out_path: return