From 78637c96a747dd15584fb85a281d447b8307ebe0 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 24 Dec 2024 17:38:46 +0000 Subject: [PATCH 01/38] Tests: Add spheres test for missing indirect conditions (#3924) Co-authored-by: Fabian Dill Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- test/general/test_implemented.py | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index e76d539451ea..756cfa8bb67d 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -52,3 +52,68 @@ def test_slot_data(self): def test_no_failed_world_loads(self): if failed_world_loads: self.fail(f"The following worlds failed to load: {failed_world_loads}") + + def test_explicit_indirect_conditions_spheres(self): + """Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit + indirect conditions""" + # Because the iteration order of blocked_connections in CollectionState.update_reachable_regions() is + # nondeterministic, this test may sometimes pass with the same seed even when there are missing indirect + # conditions. + for game_name, world_type in AutoWorldRegister.world_types.items(): + multiworld = setup_solo_multiworld(world_type) + world = multiworld.get_game_worlds(game_name)[0] + if not world.explicit_indirect_conditions: + # The world does not use explicit indirect conditions, so it can be skipped. + continue + # The world may override explicit_indirect_conditions as a property that cannot be set, so try modifying it. + try: + world.explicit_indirect_conditions = False + world.explicit_indirect_conditions = True + except Exception: + # Could not modify the attribute, so skip this world. + with self.subTest(game=game_name, skipped="world.explicit_indirect_conditions could not be set"): + continue + with self.subTest(game=game_name, seed=multiworld.seed): + distribute_items_restrictive(multiworld) + call_all(multiworld, "post_fill") + + # Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked + # is nondeterministic and may vary between runs with the same seed. + explicit_spheres = list(multiworld.get_spheres()) + # Disable explicit indirect conditions and produce a second list of spheres. + world.explicit_indirect_conditions = False + implicit_spheres = list(multiworld.get_spheres()) + + # Both lists should be identical. + if explicit_spheres == implicit_spheres: + # Test passed. + continue + + # Find the first sphere that was different and provide a useful failure message. + zipped = zip(explicit_spheres, implicit_spheres) + for sphere_num, (sphere_explicit, sphere_implicit) in enumerate(zipped, start=1): + # Each sphere created with explicit indirect conditions should be identical to the sphere created + # with implicit indirect conditions. + if sphere_explicit != sphere_implicit: + reachable_only_with_implicit = sorted(sphere_implicit - sphere_explicit) + if reachable_only_with_implicit: + locations_and_parents = [(loc, loc.parent_region) for loc in reachable_only_with_implicit] + self.fail(f"Sphere {sphere_num} created with explicit indirect conditions did not contain" + f" the same locations as sphere {sphere_num} created with implicit indirect" + f" conditions. There may be missing indirect conditions for connections to the" + f" locations' parent regions or connections from other regions which connect to" + f" these regions." + f"\nLocations that should have been reachable in sphere {sphere_num} and their" + f" parent regions:" + f"\n{locations_and_parents}") + else: + # Some locations were only present in the sphere created with explicit indirect conditions. + # This should not happen because missing indirect conditions should only reduce + # accessibility, not increase accessibility. + reachable_only_with_explicit = sorted(sphere_explicit - sphere_implicit) + self.fail(f"Sphere {sphere_num} created with explicit indirect conditions contained more" + f" locations than sphere {sphere_num} created with implicit indirect conditions." + f" This should not happen." + f"\nUnexpectedly reachable locations in sphere {sphere_num}:" + f"\n{reachable_only_with_explicit}") + self.fail("Unreachable") From 5578ccd578be4aff3b4542970f8bd7cacac5c526 Mon Sep 17 00:00:00 2001 From: Dinopony Date: Tue, 24 Dec 2024 20:08:03 +0100 Subject: [PATCH 02/38] Landstalker: Fix issues on generation (#4345) --- worlds/landstalker/Constants.py | 28 +++++++++++++++ worlds/landstalker/Hints.py | 2 +- worlds/landstalker/Items.py | 3 +- worlds/landstalker/Locations.py | 16 +++++---- worlds/landstalker/__init__.py | 20 ++++++++--- worlds/landstalker/data/world_node.py | 36 +++++++++++++++++++ worlds/landstalker/data/world_path.py | 25 +++++++++++++ worlds/landstalker/data/world_region.py | 13 ++++--- .../landstalker/data/world_teleport_tree.py | 10 +++--- 9 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 worlds/landstalker/Constants.py diff --git a/worlds/landstalker/Constants.py b/worlds/landstalker/Constants.py new file mode 100644 index 000000000000..ad4dc6ce7ae6 --- /dev/null +++ b/worlds/landstalker/Constants.py @@ -0,0 +1,28 @@ + +BASE_ITEM_ID = 4000 + +BASE_LOCATION_ID = 4000 +BASE_GROUND_LOCATION_ID = BASE_LOCATION_ID + 256 +BASE_SHOP_LOCATION_ID = BASE_GROUND_LOCATION_ID + 30 +BASE_REWARD_LOCATION_ID = BASE_SHOP_LOCATION_ID + 50 + +ENDGAME_REGIONS = [ + "kazalt", + "king_nole_labyrinth_pre_door", + "king_nole_labyrinth_post_door", + "king_nole_labyrinth_exterior", + "king_nole_labyrinth_fall_from_exterior", + "king_nole_labyrinth_path_to_palace", + "king_nole_labyrinth_raft_entrance", + "king_nole_labyrinth_raft", + "king_nole_labyrinth_sacred_tree", + "king_nole_palace" +] + +ENDGAME_PROGRESSION_ITEMS = [ + "Gola's Nail", + "Gola's Fang", + "Gola's Horn", + "Logs", + "Snow Spikes" +] \ No newline at end of file diff --git a/worlds/landstalker/Hints.py b/worlds/landstalker/Hints.py index 5309e85032ea..4211e0ef3bb1 100644 --- a/worlds/landstalker/Hints.py +++ b/worlds/landstalker/Hints.py @@ -45,7 +45,7 @@ def generate_lithograph_hint(world: "LandstalkerWorld"): words.append(item.name.split(" ")[0].upper()) if item.location.player != world.player: # Add player name if it's not in our own world - player_name = world.multiworld.get_player_name(world.player) + player_name = world.multiworld.get_player_name(item.location.player) words.append(player_name.upper()) world.random.shuffle(words) hint_text += " ".join(words) + "\n" diff --git a/worlds/landstalker/Items.py b/worlds/landstalker/Items.py index ad7efa1cb27a..6424a37f9a1e 100644 --- a/worlds/landstalker/Items.py +++ b/worlds/landstalker/Items.py @@ -1,8 +1,7 @@ from typing import Dict, List, NamedTuple from BaseClasses import Item, ItemClassification - -BASE_ITEM_ID = 4000 +from .Constants import BASE_ITEM_ID class LandstalkerItem(Item): diff --git a/worlds/landstalker/Locations.py b/worlds/landstalker/Locations.py index 0fe63526c63b..25d02ca527f4 100644 --- a/worlds/landstalker/Locations.py +++ b/worlds/landstalker/Locations.py @@ -1,15 +1,11 @@ from typing import Dict, Optional from BaseClasses import Location, ItemClassification, Item +from .Constants import * from .Regions import LandstalkerRegion from .data.item_source import ITEM_SOURCES_JSON from .data.world_path import WORLD_PATHS_JSON -BASE_LOCATION_ID = 4000 -BASE_GROUND_LOCATION_ID = BASE_LOCATION_ID + 256 -BASE_SHOP_LOCATION_ID = BASE_GROUND_LOCATION_ID + 30 -BASE_REWARD_LOCATION_ID = BASE_SHOP_LOCATION_ID + 50 - class LandstalkerLocation(Location): game: str = "Landstalker - The Treasures of King Nole" @@ -21,10 +17,14 @@ def __init__(self, player: int, name: str, location_id: Optional[int], region: L self.type_string = type_string -def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], name_to_id_table: Dict[str, int]): +def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], + name_to_id_table: Dict[str, int], reach_kazalt_goal: bool): # Create real locations from the data inside the corresponding JSON file for data in ITEM_SOURCES_JSON: region_id = data["nodeId"] + # If "Reach Kazalt" goal is enabled and location is beyond Kazalt, don't create it + if reach_kazalt_goal and region_id in ENDGAME_REGIONS: + continue region = regions_table[region_id] new_location = LandstalkerLocation(player, data["name"], name_to_id_table[data["name"]], region, data["type"]) region.locations.append(new_location) @@ -32,6 +32,10 @@ def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], n # Create fake event locations that will be used to determine if some key regions has been visited regions_with_entrance_checks = [] for data in WORLD_PATHS_JSON: + # If "Reach Kazalt" goal is enabled and region is beyond Kazalt, don't create any event for it since it would + # be useless anyway + if reach_kazalt_goal and data["fromId"] in ENDGAME_REGIONS: + continue if "requiredNodes" in data: regions_with_entrance_checks.extend([region_id for region_id in data["requiredNodes"]]) regions_with_entrance_checks = sorted(set(regions_with_entrance_checks)) diff --git a/worlds/landstalker/__init__.py b/worlds/landstalker/__init__.py index 8463e56e54c1..cfdc335c484e 100644 --- a/worlds/landstalker/__init__.py +++ b/worlds/landstalker/__init__.py @@ -2,6 +2,7 @@ from BaseClasses import LocationProgressType, Tutorial from worlds.AutoWorld import WebWorld, World +from .Constants import * from .Hints import * from .Items import * from .Locations import * @@ -87,7 +88,8 @@ def generate_early(self): def create_regions(self): self.regions_table = Regions.create_regions(self) - Locations.create_locations(self.player, self.regions_table, self.location_name_to_id) + Locations.create_locations(self.player, self.regions_table, self.location_name_to_id, + self.options.goal == "reach_kazalt") self.create_teleportation_trees() def create_item(self, name: str, classification_override: Optional[ItemClassification] = None) -> LandstalkerItem: @@ -109,7 +111,16 @@ def create_items(self): # If item is an armor and progressive armors are enabled, transform it into a progressive armor item if self.options.progressive_armors and "Breast" in name: name = "Progressive Armor" - item_pool += [self.create_item(name) for _ in range(data.quantity)] + + qty = data.quantity + if self.options.goal == "reach_kazalt": + # In "Reach Kazalt" goal, remove all endgame progression items that would be useless anyway + if name in ENDGAME_PROGRESSION_ITEMS: + continue + # Also reduce quantities for most filler items to let space for more EkeEke (see end of function) + if data.classification == ItemClassification.filler: + qty = int(qty * 0.8) + item_pool += [self.create_item(name) for _ in range(qty)] # If the appropriate setting is on, place one EkeEke in one shop in every town in the game if self.options.ensure_ekeeke_in_shops: @@ -120,9 +131,10 @@ def create_items(self): "Mercator: Shop item #1", "Verla: Shop item #1", "Destel: Inn item", - "Route to Lake Shrine: Greedly's shop item #1", - "Kazalt: Shop item #1" + "Route to Lake Shrine: Greedly's shop item #1" ] + if self.options.goal != "reach_kazalt": + shops_to_fill.append("Kazalt: Shop item #1") for location_name in shops_to_fill: self.multiworld.get_location(location_name, self.player).place_locked_item(self.create_item("EkeEke")) diff --git a/worlds/landstalker/data/world_node.py b/worlds/landstalker/data/world_node.py index f786f9613fba..0b0c56a74e69 100644 --- a/worlds/landstalker/data/world_node.py +++ b/worlds/landstalker/data/world_node.py @@ -73,6 +73,22 @@ "between Gumi and Ryuma" ] }, + "tibor_tree": { + "name": "Route from Gumi to Ryuma (Tibor tree)", + "hints": [ + "on a route", + "in a region inhabited by bears", + "between Gumi and Ryuma" + ] + }, + "mercator_gate_tree": { + "name": "Route from Gumi to Ryuma (Mercator gate tree)", + "hints": [ + "on a route", + "in a region inhabited by bears", + "between Gumi and Ryuma" + ] + }, "tibor": { "name": "Tibor", "hints": [ @@ -223,6 +239,13 @@ "in the infamous Greenmaze" ] }, + "greenmaze_post_whistle_tree": { + "name": "Greenmaze (post-whistle tree)", + "hints": [ + "among the trees", + "in the infamous Greenmaze" + ] + }, "verla_shore": { "name": "Verla shore", "hints": [ @@ -230,6 +253,13 @@ "near the town of Verla" ] }, + "verla_shore_tree": { + "name": "Verla shore tree", + "hints": [ + "on a route", + "near the town of Verla" + ] + }, "verla_shore_cliff": { "name": "Verla shore cliff (accessible from Verla Mines)", "hints": [ @@ -326,6 +356,12 @@ "in a mountainous area" ] }, + "mountainous_area_tree": { + "name": "Mountainous Area tree", + "hints": [ + "in a mountainous area" + ] + }, "king_nole_cave": { "name": "King Nole's Cave", "hints": [ diff --git a/worlds/landstalker/data/world_path.py b/worlds/landstalker/data/world_path.py index f7baba358a48..572149a73529 100644 --- a/worlds/landstalker/data/world_path.py +++ b/worlds/landstalker/data/world_path.py @@ -54,6 +54,16 @@ "toId": "ryuma", "twoWay": True }, + { + "fromId": "route_gumi_ryuma", + "toId": "tibor_tree", + "twoWay": True + }, + { + "fromId": "route_gumi_ryuma", + "toId": "mercator_gate_tree", + "twoWay": True + }, { "fromId": "ryuma", "toId": "ryuma_after_thieves_hideout", @@ -211,6 +221,11 @@ ], "twoWay": True }, + { + "fromId": "greenmaze_post_whistle", + "toId": "greenmaze_post_whistle_tree", + "twoWay": True + }, { "fromId": "greenmaze_post_whistle", "toId": "route_massan_gumi" @@ -253,6 +268,11 @@ "fromId": "verla_shore_cliff", "toId": "verla_shore" }, + { + "fromId": "verla_shore", + "toId": "verla_shore_tree", + "twoWay": True + }, { "fromId": "verla_shore", "toId": "mir_tower_sector", @@ -316,6 +336,11 @@ "Axe Magic" ] }, + { + "fromId": "mountainous_area", + "toId": "mountainous_area_tree", + "twoWay": True + }, { "fromId": "mountainous_area", "toId": "route_lake_shrine_cliff", diff --git a/worlds/landstalker/data/world_region.py b/worlds/landstalker/data/world_region.py index 3365a9dfa9e2..81ff94452257 100644 --- a/worlds/landstalker/data/world_region.py +++ b/worlds/landstalker/data/world_region.py @@ -57,7 +57,9 @@ "name": "Route between Gumi and Ryuma", "canBeHintedAsRequired": False, "nodeIds": [ - "route_gumi_ryuma" + "route_gumi_ryuma", + "tibor_tree", + "mercator_gate_tree" ] }, { @@ -157,7 +159,8 @@ "hintName": "in Greenmaze", "nodeIds": [ "greenmaze_pre_whistle", - "greenmaze_post_whistle" + "greenmaze_post_whistle", + "greenmaze_post_whistle_tree" ] }, { @@ -165,7 +168,8 @@ "canBeHintedAsRequired": False, "nodeIds": [ "verla_shore", - "verla_shore_cliff" + "verla_shore_cliff", + "verla_shore_tree" ] }, { @@ -244,7 +248,8 @@ "name": "Mountainous Area", "hintName": "in the mountainous area", "nodeIds": [ - "mountainous_area" + "mountainous_area", + "mountainous_area_tree" ] }, { diff --git a/worlds/landstalker/data/world_teleport_tree.py b/worlds/landstalker/data/world_teleport_tree.py index 830f5547201e..f3b92affd3a6 100644 --- a/worlds/landstalker/data/world_teleport_tree.py +++ b/worlds/landstalker/data/world_teleport_tree.py @@ -8,19 +8,19 @@ { "name": "Tibor tree", "treeMapId": 534, - "nodeId": "route_gumi_ryuma" + "nodeId": "tibor_tree" } ], [ { "name": "Mercator front gate tree", "treeMapId": 539, - "nodeId": "route_gumi_ryuma" + "nodeId": "mercator_gate_tree" }, { "name": "Verla shore tree", "treeMapId": 537, - "nodeId": "verla_shore" + "nodeId": "verla_shore_tree" } ], [ @@ -44,7 +44,7 @@ { "name": "Mountainous area tree", "treeMapId": 535, - "nodeId": "mountainous_area" + "nodeId": "mountainous_area_tree" } ], [ @@ -56,7 +56,7 @@ { "name": "Greenmaze end tree", "treeMapId": 511, - "nodeId": "greenmaze_post_whistle" + "nodeId": "greenmaze_post_whistle_tree" } ] ] \ No newline at end of file From 6c1dc5f645ad215347eaecc2a5f5de0d2fd13365 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Wed, 25 Dec 2024 01:44:47 +0000 Subject: [PATCH 03/38] Landstalker: Fix paths Lantern logic affecting other Landstalker worlds (#4394) The data from `WORLD_PATHS_JSON` is supposed to be constant logic data shared by all Landstalker worlds, but `add_path_requirements()` was modifying this data such that after adding a `Lantern` requirement for a dark region, subsequent Landstalker worlds to have their logic set could also be affected by this `Lantern` requirement and previous Landstalker worlds without damage boosting logic could also be affected by this `Lantern` requirement because they could all be using the same list instances. This issue would only occur for paths that have `"requiredItems"` because all paths without required items would create a new empty list, avoiding the problem. The items in `data["itemsPlacedWhenCrossing"]` were also getting added once for each Landstalker player, but there are no paths that have both `"itemsPlacedWhenCrossing"` and `"requiredItems"`, so all such cases would start from a new empty list of required items and avoid modifying `WORLD_PATHS_JSON`. --- worlds/landstalker/Rules.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/landstalker/Rules.py b/worlds/landstalker/Rules.py index 94171944d7b2..60f4cdde2901 100644 --- a/worlds/landstalker/Rules.py +++ b/worlds/landstalker/Rules.py @@ -37,7 +37,8 @@ def add_path_requirements(world: "LandstalkerWorld"): name = data["fromId"] + " -> " + data["toId"] # Determine required items to reach this region - required_items = data["requiredItems"] if "requiredItems" in data else [] + # WORLD_PATHS_JSON is shared by all Landstalker worlds, so a copy is made to prevent modifying the original + required_items = data["requiredItems"].copy() if "requiredItems" in data else [] if "itemsPlacedWhenCrossing" in data: required_items += data["itemsPlacedWhenCrossing"] From b05f81b4b4f8f63368c7ebcf0aa5d3223357e1ce Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 10:58:27 +0100 Subject: [PATCH 04/38] =?UTF-8?q?The=20Witness:=20Fix=20bridge/elevator=20?= =?UTF-8?q?items=20being=20progression=20when=20they=20shouldn't=20be?= =?UTF-8?q?=C2=A0#4392?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/witness/player_logic.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 9e6c9597382b..aea2953abb50 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -927,7 +927,6 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: # Gather quick references to relevant options eps_shuffled = world.options.shuffle_EPs - come_to_you = world.options.elevators_come_to_you difficulty = world.options.puzzle_randomization discards_shuffled = world.options.shuffle_discarded_panels boat_shuffled = world.options.shuffle_boat @@ -939,6 +938,9 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: shortbox_req = world.options.mountain_lasers longbox_req = world.options.challenge_lasers + swamp_bridge_comes_to_you = "Swamp Long Bridge" in world.options.elevators_come_to_you + quarry_elevator_comes_to_you = "Quarry Elevator" in world.options.elevators_come_to_you + # Make some helper booleans so it is easier to follow what's going on mountain_upper_is_in_postgame = ( goal == "mountain_box_short" @@ -956,8 +958,8 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: "0x17D02": eps_shuffled, # Windmill Turn Control "0x0368A": symbols_shuffled or door_panels, # Quarry Stoneworks Stairs Door "0x3865F": symbols_shuffled or door_panels or eps_shuffled, # Quarry Boathouse 2nd Barrier - "0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel - "0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge + "0x17CC4": quarry_elevator_comes_to_you or eps_shuffled, # Quarry Elevator Panel + "0x17E2B": swamp_bridge_comes_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge "0x0CF2A": False, # Jungle Monastery Garden Shortcut "0x0364E": False, # Monastery Laser Shortcut Door "0x03713": remote_doors, # Monastery Laser Shortcut Panel From 845000d10faa8cdf1c6ac293dcdfecc4c69a213d Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:47:17 +0100 Subject: [PATCH 05/38] Docs: Make an actual LogicMixin spec & explanation (#3975) * Docs: Make an actual LogicMixin spec & explanation * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update world api.md * Update docs/world api.md Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update docs/world api.md * Update world api.md * Code corrections / actually follow own spec * Update docs/world api.md Co-authored-by: Scipio Wright * Update world api.md * Update world api.md * Reorganize / Rewrite the parts about optimisations a bit * Update world api.md * Write a big motivation paragraph * Update world api.md * Update world api.md * line break issues * Update docs/world api.md Co-authored-by: Scipio Wright * Update docs/world api.md Co-authored-by: Scipio Wright * Update docs/world api.md Co-authored-by: Scipio Wright * Update world api.md * Update docs/world api.md Co-authored-by: Scipio Wright --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: Scipio Wright --- docs/world api.md | 89 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index 20669d7ae7be..445e68e71e3c 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -699,9 +699,92 @@ When importing a file that defines a class that inherits from `worlds.AutoWorld. is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing world since the namespace is shared with all other logic mixins. -Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified -with the state. -Please do this with caution and only when necessary. +LogicMixin is handy when your logic is more complex than one-to-one location-item relationships. +A game in which "The red key opens the red door" can just express this relationship through a one-line access rule. +But now, consider a game with a heavy focus on combat, where the main logical consideration is which enemies you can +defeat with your current items. +There could be dozens of weapons, armor pieces, or consumables that each improve your ability to defeat +specific enemies to varying degrees. It would be useful to be able to keep track of "defeatable enemies" as a state variable, +and have this variable be recalculated as necessary based on newly collected/removed items. +This is the capability of LogicMixin: Adding custom variables to state that get recalculated as necessary. + +In general, a LogicMixin class should have at least one mutable variable that is tracking some custom state per player, +as well as `init_mixin` and `copy_mixin` functions so that this variable gets initialized and copied correctly when +`CollectionState()` and `CollectionState.copy()` are called respectively. + +```python +from BaseClasses import CollectionState, MultiWorld +from worlds.AutoWorld import LogicMixin + +class MyGameState(LogicMixin): + mygame_defeatable_enemies: Dict[int, Set[str]] # per player + + def init_mixin(self, multiworld: MultiWorld) -> None: + # Initialize per player with the corresponding "nothing" value, such as 0 or an empty set. + # You can also use something like Collections.defaultdict + self.mygame_defeatable_enemies = { + player: set() for player in multiworld.get_game_players("My Game") + } + + def copy_mixin(self, new_state: CollectionState) -> CollectionState: + # Be careful to make a "deep enough" copy here! + new_state.mygame_defeatable_enemies = { + player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items() + } +``` + +After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules. + +Usually, doing this coincides with an override of `World.collect` and `World.remove`, where the custom state variable +gets recalculated when a relevant item is collected or removed. + +```python +# __init__.py + +def collect(self, state: CollectionState, item: Item) -> bool: + change = super().collect(state, item) + if change and item in COMBAT_ITEMS: + state.mygame_defeatable_enemies[self.player] |= get_newly_unlocked_enemies(state) + return change + +def remove(self, state: CollectionState, item: Item) -> bool: + change = super().remove(state, item) + if change and item in COMBAT_ITEMS: + state.mygame_defeatable_enemies[self.player] -= get_newly_locked_enemies(state) + return change +``` + +Using LogicMixin can greatly slow down your code if you don't use it intelligently. This is because `collect` +and `remove` are called very frequently during fill. If your `collect` & `remove` cause a heavy calculation +every time, your code might end up being *slower* than just doing calculations in your access rules. + +One way to optimise recalculations is to make use of the fact that `collect` should only unlock things, +and `remove` should only lock things. +In our example, we have two different functions: `get_newly_unlocked_enemies` and `get_newly_locked_enemies`. +`get_newly_unlocked_enemies` should only consider enemies that are *not already in the set* +and check whether they were **unlocked**. +`get_newly_locked_enemies` should only consider enemies that are *already in the set* +and check whether they **became locked**. + +Another impactful way to optimise LogicMixin is to use caching. +Your custom state variables don't actually need to be recalculated on every `collect` / `remove`, because there are +often multiple calls to `collect` / `remove` between access rule calls. Thus, it would be much more efficient to hold +off on recaculating until the an actual access rule call happens. +A common way to realize this is to define a `mygame_state_is_stale` variable that is set to True in `collect`, `remove`, +and `init_mixin`. The calls to the actual recalculating functions are then moved to the start of the relevant +access rules like this: + +```python +def can_defeat_enemy(state: CollectionState, player: int, enemy: str) -> bool: + if state.mygame_state_is_stale[player]: + state.mygame_defeatable_enemies[player] = recalculate_defeatable_enemies(state) + state.mygame_state_is_stale[player] = False + + return enemy in state.mygame_defeatable_enemies[player] +``` + +Only use LogicMixin if necessary. There are often other ways to achieve what it does, like making clever use of +`state.prog_items`, using event items, pseudo-regions, etc. #### pre_fill From 222c8aa0ae0ebbedb9884812087c38e15e381ed1 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:47:51 +0100 Subject: [PATCH 06/38] Core: Reword item classification definitions to allow for progression + useful (#3925) * Core: Reword item classification definitions to allow for progression + useful * Update network protocol.md * Update world api.md * Update Fill.py * Docstrings * Update BaseClasses.py * Update advanced_settings_en.md * Update advanced_settings_en.md * Update advanced_settings_en.md * space --- BaseClasses.py | 27 +++++++++++++++------ Fill.py | 2 +- docs/network protocol.md | 2 +- docs/world api.md | 3 ++- worlds/generic/docs/advanced_settings_en.md | 4 +-- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 98ada4f861ec..e5c187b9117f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1254,13 +1254,26 @@ def hint_text(self) -> str: class ItemClassification(IntFlag): - filler = 0b0000 # aka trash, as in filler items like ammo, currency etc, - progression = 0b0001 # Item that is logically relevant - useful = 0b0010 # Item that is generally quite useful, but not required for anything logical - trap = 0b0100 # detrimental item - skip_balancing = 0b1000 # should technically never occur on its own - # Item that is logically relevant, but progression balancing should not touch. - # Typically currency or other counted items. + filler = 0b0000 + """ aka trash, as in filler items like ammo, currency etc """ + + progression = 0b0001 + """ Item that is logically relevant. + Protects this item from being placed on excluded or unreachable locations. """ + + useful = 0b0010 + """ Item that is especially useful. + Protects this item from being placed on excluded or unreachable locations. + When combined with another flag like "progression", it means "an especially useful progression item". """ + + trap = 0b0100 + """ Item that is detrimental in some way. """ + + skip_balancing = 0b1000 + """ should technically never occur on its own + Item that is logically relevant, but progression balancing should not touch. + Typically currency or other counted items. """ + progression_skip_balancing = 0b1001 # only progression gets balanced def as_flag(self) -> int: diff --git a/Fill.py b/Fill.py index 86a4639c51ce..45c4def9e322 100644 --- a/Fill.py +++ b/Fill.py @@ -537,7 +537,7 @@ def mark_for_locking(location: Location): if excludedlocations: raise FillError( f"Not enough filler items for excluded locations. " - f"There are {len(excludedlocations)} more excluded locations than filler or trap items.", + f"There are {len(excludedlocations)} more excluded locations than excludable items.", multiworld=multiworld, ) diff --git a/docs/network protocol.md b/docs/network protocol.md index 2ad8d4c4d1bc..160f83031c9b 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -540,7 +540,7 @@ In JSON this may look like: | ----- | ----- | | 0 | Nothing special about this item | | 0b001 | If set, indicates the item can unlock logical advancement | -| 0b010 | If set, indicates the item is important but not in a way that unlocks advancement | +| 0b010 | If set, indicates the item is especially useful | | 0b100 | If set, indicates the item is a trap | ### JSONMessagePart diff --git a/docs/world api.md b/docs/world api.md index 445e68e71e3c..487c5b4a360c 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -248,7 +248,8 @@ will all have the same ID. Name must not be numeric (must contain at least 1 let Other classifications include: * `filler`: a regular item or trash item -* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations +* `useful`: item that is especially useful. Cannot be placed on excluded or unreachable locations. When combined with +another flag like "progression", it means "an especially useful progression item". * `trap`: negative impact on the player * `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be combined with `progression`; see below) diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 2197c0708e9c..e78eb91592a3 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -131,8 +131,8 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) the location without using any hint points. * `start_location_hints` is the same as `start_hints` but for locations, allowing you to hint for the item contained there without using any hint points. -* `exclude_locations` lets you define any locations that you don't want to do and forces a filler or trap item which - isn't necessary for progression into these locations. +* `exclude_locations` lets you define any locations that you don't want to do and prevents items classified as + "progression" or "useful" from being placed on them. * `priority_locations` lets you define any locations that you want to do and forces a progression item into these locations. * `item_links` allows players to link their items into a group with the same item link name and game. The items declared From fe810535211ca9ab57ed3b7649a272035d59e3a7 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:53:05 +0100 Subject: [PATCH 07/38] Core: Give the option to worlds to have a remaining fill that respects excluded locations (#3738) * Give the option to worlds to have a remaining fill that respects excluded * comment --- Fill.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Fill.py b/Fill.py index 45c4def9e322..5bbbfa79c28f 100644 --- a/Fill.py +++ b/Fill.py @@ -235,18 +235,30 @@ def remaining_fill(multiworld: MultiWorld, locations: typing.List[Location], itempool: typing.List[Item], name: str = "Remaining", - move_unplaceable_to_start_inventory: bool = False) -> None: + move_unplaceable_to_start_inventory: bool = False, + check_location_can_fill: bool = False) -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() total = min(len(itempool), len(locations)) placed = 0 + + # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule + if check_location_can_fill: + state = CollectionState(multiworld) + + def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): + return location_to_fill.can_fill(state, item_to_fill, check_access=False) + else: + def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): + return location_to_fill.item_rule(item_to_fill) + while locations and itempool: item_to_place = itempool.pop() spot_to_fill: typing.Optional[Location] = None for i, location in enumerate(locations): - if location.item_rule(item_to_place): + if location_can_fill_item(location, item_to_place): # popping by index is faster than removing by content, spot_to_fill = locations.pop(i) # skipping a scan for the element @@ -267,7 +279,7 @@ def remaining_fill(multiworld: MultiWorld, location.item = None placed_item.location = None - if location.item_rule(item_to_place): + if location_can_fill_item(location, item_to_place): # Add this item to the existing placement, and # add the old item to the back of the queue spot_to_fill = placements.pop(i) From 62942704bdea4ba0f79cb88580d5214b31b750b5 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Wed, 25 Dec 2024 21:55:15 +0100 Subject: [PATCH 08/38] The Witness: Add info about which door items exist in the pool to slot data (#3583) * This feature is just broken lol * simplify * mypy * Expand the unit test for forbidden doors --- worlds/witness/__init__.py | 9 ++--- worlds/witness/player_items.py | 19 +++------- worlds/witness/test/test_door_shuffle.py | 47 ++++++++++++++++++++---- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index ac9197bd92bb..471d030d4897 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -84,7 +84,8 @@ def _get_slot_data(self) -> Dict[str, Any]: "victory_location": int(self.player_logic.VICTORY_LOCATION, 16), "panelhex_to_id": self.player_locations.CHECK_PANELHEX_TO_ID, "item_id_to_door_hexes": static_witness_items.get_item_to_door_mappings(), - "door_hexes_in_the_pool": self.player_items.get_door_ids_in_pool(), + "door_items_in_the_pool": self.player_items.get_door_item_ids_in_pool(), + "doors_that_shouldnt_be_locked": [int(h, 16) for h in self.player_logic.FORBIDDEN_DOORS], "symbols_not_in_the_game": self.player_items.get_symbol_ids_not_in_pool(), "disabled_entities": [int(h, 16) for h in self.player_logic.COMPLETELY_DISABLED_ENTITIES], "hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES], @@ -150,7 +151,8 @@ def generate_early(self) -> None: ) self.player_regions: WitnessPlayerRegions = WitnessPlayerRegions(self.player_locations, self) - self.log_ids_to_hints = {} + self.log_ids_to_hints: Dict[int, CompactHintData] = {} + self.laser_ids_to_hints: Dict[int, CompactHintData] = {} self.determine_sufficient_progression() @@ -325,9 +327,6 @@ def create_items(self) -> None: self.options.local_items.value.add(item_name) def fill_slot_data(self) -> Dict[str, Any]: - self.log_ids_to_hints: Dict[int, CompactHintData] = {} - self.laser_ids_to_hints: Dict[int, CompactHintData] = {} - already_hinted_locations = set() # Laser hints diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 2fb987bb456a..e40d261d8a97 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -222,20 +222,15 @@ def get_early_items(self) -> List[str]: # Sort the output for consistency across versions if the implementation changes but the logic does not. return sorted(output) - def get_door_ids_in_pool(self) -> List[int]: + def get_door_item_ids_in_pool(self) -> List[int]: """ - Returns the total set of all door IDs that are controlled by items in the pool. + Returns the ids of all door items that exist in the pool. """ - output: List[int] = [] - for item_name, item_data in self.item_data.items(): - if not isinstance(item_data.definition, DoorItemDefinition): - continue - - output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes - if hex_string not in self._logic.FORBIDDEN_DOORS] - - return output + return [ + cast_not_none(item_data.ap_code) for item_data in self.item_data.values() + if isinstance(item_data.definition, DoorItemDefinition) + ] def get_symbol_ids_not_in_pool(self) -> List[int]: """ @@ -257,5 +252,3 @@ def get_progressive_item_ids_in_pool(self) -> Dict[int, List[int]]: output[cast_not_none(item.ap_code)] = [cast_not_none(static_witness_items.ITEM_DATA[child_item].ap_code) for child_item in item.definition.child_item_names] return output - - diff --git a/worlds/witness/test/test_door_shuffle.py b/worlds/witness/test/test_door_shuffle.py index d593a84bdb8f..ca4d6e0aa83e 100644 --- a/worlds/witness/test/test_door_shuffle.py +++ b/worlds/witness/test/test_door_shuffle.py @@ -1,3 +1,6 @@ +from typing import cast + +from .. import WitnessWorld from ..test import WitnessMultiworldTestBase, WitnessTestBase @@ -32,6 +35,10 @@ class TestForbiddenDoors(WitnessMultiworldTestBase): { "early_caves": "add_to_pool", }, + { + "early_caves": "add_to_pool", + "door_groupings": "regional", + }, ] common_options = { @@ -40,11 +47,35 @@ class TestForbiddenDoors(WitnessMultiworldTestBase): } def test_forbidden_doors(self) -> None: - self.assertTrue( - self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1), - "Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't." - ) - self.assertFalse( - self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2), - "Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists." - ) + with self.subTest("Test that Caves Mountain Shortcut (Panel) exists if Early Caves is off"): + self.assertTrue( + self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1), + "Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't." + ) + + with self.subTest("Test that Caves Mountain Shortcut (Panel) doesn't exist if Early Caves is start_to_pool"): + self.assertFalse( + self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2), + "Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists." + ) + + with self.subTest("Test that slot data is set up correctly for a panels seed with Early Caves"): + slot_data = cast(WitnessWorld, self.multiworld.worlds[3])._get_slot_data() + + self.assertIn( + WitnessWorld.item_name_to_id["Caves Panels"], + slot_data["door_items_in_the_pool"], + 'Caves Panels should still exist in slot_data under "door_items_in_the_pool".' + ) + + self.assertIn( + 0x021D7, + slot_data["item_id_to_door_hexes"][WitnessWorld.item_name_to_id["Caves Panels"]], + "Caves Panels should still contain Caves Mountain Shortcut Panel as a door they unlock.", + ) + + self.assertIn( + 0x021D7, + slot_data["doors_that_shouldnt_be_locked"], + "Caves Mountain Shortcut Panel should be marked as \"shouldn't be locked\".", + ) From 33ae68c756f71eac6203b302db0144dee04ab09f Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Thu, 26 Dec 2024 13:50:18 +0000 Subject: [PATCH 09/38] DS3: Convert post_fill to stage_post_fill for better performance (#4122) Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/dark_souls_3/__init__.py | 214 +++++++++++++++++--------------- 1 file changed, 117 insertions(+), 97 deletions(-) diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 765ffb1fc544..e1787a9a44aa 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -1366,7 +1366,8 @@ def write_spoiler(self, spoiler_handle: TextIO) -> None: text = "\n" + text + "\n" spoiler_handle.write(text) - def post_fill(self): + @classmethod + def stage_post_fill(cls, multiworld: MultiWorld): """If item smoothing is enabled, rearrange items so they scale up smoothly through the run. This determines the approximate order a given silo of items (say, soul items) show up in the @@ -1375,106 +1376,125 @@ def post_fill(self): items, later spheres get higher-level ones. Within a sphere, items in DS3 are distributed in region order, and then the best items in a sphere go into the multiworld. """ + ds3_worlds = [world for world in cast(List[DarkSouls3World], multiworld.get_game_worlds(cls.game)) if + world.options.smooth_upgrade_items + or world.options.smooth_soul_items + or world.options.smooth_upgraded_weapons] + if not ds3_worlds: + # No worlds need item smoothing. + return - locations_by_sphere = [ - sorted(loc for loc in sphere if loc.item.player == self.player and not loc.locked) - for sphere in self.multiworld.get_spheres() - ] - - # All items in the base game in approximately the order they appear - all_item_order: List[DS3ItemData] = [ - item_dictionary[location.default_item_name] - for region in region_order - # Shuffle locations within each region. - for location in self._shuffle(location_tables[region]) - if self._is_location_available(location) - ] - - # All DarkSouls3Items for this world that have been assigned anywhere, grouped by name - full_items_by_name: Dict[str, List[DarkSouls3Item]] = defaultdict(list) - for location in self.multiworld.get_filled_locations(): - if location.item.player == self.player and ( - location.player != self.player or self._is_location_available(location) - ): - full_items_by_name[location.item.name].append(location.item) - - def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None: - """Rearrange all items in item_order to match that order. - - Note: this requires that item_order exactly matches the number of placed items from this - world matching the given names. - """ - - # Convert items to full DarkSouls3Items. - converted_item_order: List[DarkSouls3Item] = [ - item for item in ( - ( - # full_items_by_name won't contain DLC items if the DLC is disabled. - (full_items_by_name[item.name] or [None]).pop(0) - if isinstance(item, DS3ItemData) else item - ) - for item in item_order - ) - # Never re-order event items, because they weren't randomized in the first place. - if item and item.code is not None - ] - - names = {item.name for item in converted_item_order} - - all_matching_locations = [ - loc - for sphere in locations_by_sphere - for loc in sphere - if loc.item.name in names + spheres_per_player: Dict[int, List[List[Location]]] = {world.player: [] for world in ds3_worlds} + for sphere in multiworld.get_spheres(): + locations_per_item_player: Dict[int, List[Location]] = {player: [] for player in spheres_per_player.keys()} + for location in sphere: + if location.locked: + continue + item_player = location.item.player + if item_player in locations_per_item_player: + locations_per_item_player[item_player].append(location) + for player, locations in locations_per_item_player.items(): + # Sort for deterministic results. + locations.sort() + spheres_per_player[player].append(locations) + + for ds3_world in ds3_worlds: + locations_by_sphere = spheres_per_player[ds3_world.player] + + # All items in the base game in approximately the order they appear + all_item_order: List[DS3ItemData] = [ + item_dictionary[location.default_item_name] + for region in region_order + # Shuffle locations within each region. + for location in ds3_world._shuffle(location_tables[region]) + if ds3_world._is_location_available(location) ] - # It's expected that there may be more total items than there are matching locations if - # the player has chosen a more limited accessibility option, since the matching - # locations *only* include items in the spheres of accessibility. - if len(converted_item_order) < len(all_matching_locations): - raise Exception( - f"DS3 bug: there are {len(all_matching_locations)} locations that can " + - f"contain smoothed items, but only {len(converted_item_order)} items to smooth." - ) - - for sphere in locations_by_sphere: - locations = [loc for loc in sphere if loc.item.name in names] - - # Check the game, not the player, because we know how to sort within regions for DS3 - offworld = self._shuffle([loc for loc in locations if loc.game != "Dark Souls III"]) - onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"), - key=lambda loc: loc.data.region_value) - - # Give offworld regions the last (best) items within a given sphere - for location in onworld + offworld: - new_item = self._pop_item(location, converted_item_order) - location.item = new_item - new_item.location = location - - if self.options.smooth_upgrade_items: - base_names = { - "Titanite Shard", "Large Titanite Shard", "Titanite Chunk", "Titanite Slab", - "Titanite Scale", "Twinkling Titanite", "Farron Coal", "Sage's Coal", "Giant's Coal", - "Profaned Coal" - } - smooth_items([item for item in all_item_order if item.base_name in base_names]) - - if self.options.smooth_soul_items: - smooth_items([ - item for item in all_item_order - if item.souls and item.classification != ItemClassification.progression - ]) + # All DarkSouls3Items for this world that have been assigned anywhere, grouped by name + full_items_by_name: Dict[str, List[DarkSouls3Item]] = defaultdict(list) + for location in multiworld.get_filled_locations(): + if location.item.player == ds3_world.player and ( + location.player != ds3_world.player or ds3_world._is_location_available(location) + ): + full_items_by_name[location.item.name].append(location.item) + + def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None: + """Rearrange all items in item_order to match that order. + + Note: this requires that item_order exactly matches the number of placed items from this + world matching the given names. + """ + + # Convert items to full DarkSouls3Items. + converted_item_order: List[DarkSouls3Item] = [ + item for item in ( + ( + # full_items_by_name won't contain DLC items if the DLC is disabled. + (full_items_by_name[item.name] or [None]).pop(0) + if isinstance(item, DS3ItemData) else item + ) + for item in item_order + ) + # Never re-order event items, because they weren't randomized in the first place. + if item and item.code is not None + ] + + names = {item.name for item in converted_item_order} + + all_matching_locations = [ + loc + for sphere in locations_by_sphere + for loc in sphere + if loc.item.name in names + ] + + # It's expected that there may be more total items than there are matching locations if + # the player has chosen a more limited accessibility option, since the matching + # locations *only* include items in the spheres of accessibility. + if len(converted_item_order) < len(all_matching_locations): + raise Exception( + f"DS3 bug: there are {len(all_matching_locations)} locations that can " + + f"contain smoothed items, but only {len(converted_item_order)} items to smooth." + ) - if self.options.smooth_upgraded_weapons: - upgraded_weapons = [ - location.item - for location in self.multiworld.get_filled_locations() - if location.item.player == self.player - and location.item.level and location.item.level > 0 - and location.item.classification != ItemClassification.progression - ] - upgraded_weapons.sort(key=lambda item: item.level) - smooth_items(upgraded_weapons) + for sphere in locations_by_sphere: + locations = [loc for loc in sphere if loc.item.name in names] + + # Check the game, not the player, because we know how to sort within regions for DS3 + offworld = ds3_world._shuffle([loc for loc in locations if loc.game != "Dark Souls III"]) + onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"), + key=lambda loc: loc.data.region_value) + + # Give offworld regions the last (best) items within a given sphere + for location in onworld + offworld: + new_item = ds3_world._pop_item(location, converted_item_order) + location.item = new_item + new_item.location = location + + if ds3_world.options.smooth_upgrade_items: + base_names = { + "Titanite Shard", "Large Titanite Shard", "Titanite Chunk", "Titanite Slab", + "Titanite Scale", "Twinkling Titanite", "Farron Coal", "Sage's Coal", "Giant's Coal", + "Profaned Coal" + } + smooth_items([item for item in all_item_order if item.base_name in base_names]) + + if ds3_world.options.smooth_soul_items: + smooth_items([ + item for item in all_item_order + if item.souls and item.classification != ItemClassification.progression + ]) + + if ds3_world.options.smooth_upgraded_weapons: + upgraded_weapons = [ + location.item + for location in multiworld.get_filled_locations() + if location.item.player == ds3_world.player + and location.item.level and location.item.level > 0 + and location.item.classification != ItemClassification.progression + ] + upgraded_weapons.sort(key=lambda item: item.level) + smooth_items(upgraded_weapons) def _shuffle(self, seq: Sequence) -> List: """Returns a shuffled copy of a sequence.""" From b9642a482f67f2358f13d2306a90673fc4f8fd9a Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Thu, 26 Dec 2024 17:04:21 -0500 Subject: [PATCH 10/38] KH2: Using fast_fill instead of fill_restrictive (#4227) --- worlds/kh2/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py index 2809460aed6a..59c77627eebe 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -2,7 +2,7 @@ from typing import List from BaseClasses import Tutorial, ItemClassification -from Fill import fill_restrictive +from Fill import fast_fill from worlds.LauncherComponents import Component, components, Type, launch_subprocess from worlds.AutoWorld import World, WebWorld from .Items import * @@ -287,7 +287,7 @@ def generate_early(self) -> None: def pre_fill(self): """ - Plandoing Events and Fill_Restrictive for donald,goofy and sora + Plandoing Events and Fast_Fill for donald,goofy and sora """ self.donald_pre_fill() self.goofy_pre_fill() @@ -431,9 +431,10 @@ def keyblade_pre_fill(self): Fills keyblade slots with abilities determined on player's setting """ keyblade_locations = [self.multiworld.get_location(location, self.player) for location in Keyblade_Slots.keys()] - state = self.multiworld.get_all_state(False) keyblade_ability_pool_copy = self.keyblade_ability_pool.copy() - fill_restrictive(self.multiworld, state, keyblade_locations, keyblade_ability_pool_copy, True, True, allow_excluded=True) + fast_fill(self.multiworld, keyblade_ability_pool_copy, keyblade_locations) + for location in keyblade_locations: + location.locked = True def starting_invo_verify(self): """ From 218f28912e0e120e4cf91a63aba627e91cc451c5 Mon Sep 17 00:00:00 2001 From: BadMagic100 Date: Fri, 27 Dec 2024 12:04:02 -0800 Subject: [PATCH 11/38] Core: Generic Entrance Rando (#2883) * Initial implementation of Generic ER * Move ERType to Entrance.Type, fix typing imports * updates based on testing (read: flailing) * Updates from feedback * Various bug fixes in ERCollectionState * Use deque instead of queue.Queue * Allow partial entrances in collection state earlier, doc improvements * Prevent early loops in region graph, improve reusability of ER stage code * Typos, grammar, PEP8, and style "fixes" * use RuntimeError instead of bare Exceptions * return tuples from connect since it's slightly faster for our purposes * move the shuffle to the beginning of find_pairing * do er_state placements within pairing lookups to remove code duplication * requested adjustments * Add some temporary performance logging * Use CollectionState to track available exits and placed regions * Add a method to automatically disconnect entrances in a coupled-compliant way Update docs and cleanup todos * Make find_placeable_exits deterministic by sorting blocked_connections set * Move EntranceType out of Entrance * Handle minimal accessibility, autodetect regions, and improvements to disconnect * Add on_connect callback to react to succeeded entrance placements * Relax island-prevention constraints after a successful run on minimal accessibility; better error message on failure * First set of unit tests for generic ER * Change on_connect to send lists, add unit tests for EntranceLookup * Fix duplicated location names in tests * Update tests after merge * Address review feedback, start docs with diagrams * Fix rendering of hidden nodes in ER doc * Move most docstring content into a docs article * Clarify when randomize_entrances can be called safely * Address review feedback * Apply suggestions from code review Co-authored-by: Aaron Wagener * Docs on ERPlacementState, add coupled/uncoupled handling to deadend detection * Documentation clarifications * Update groups to allow any hashable * Restrict groups from hashable to int * Implement speculative sweeping in stage 1, address misc review comments * Clean unused imports in BaseClasses.py * Restrictive region/speculative sweep test * sweep_for_events->advancement * Remove redundant __str__ Co-authored-by: Doug Hoskisson * Allow partial entrances in auto indirect condition sweep * Treat regions needed for logic as non-dead-end regardless of if they have exits, flip order of stage 3 and 4 to ensure there are enough exits for the dead ends * Typing fixes suggested by mypy * Remove erroneous newline Not sure why the merge conflict editor is different and worse than the normal editor. Crazy * Use modern typing for ER * Enforce the use of explicit indirect conditions * Improve doc on required indirect conditions --------- Co-authored-by: qwint Co-authored-by: alwaysintreble Co-authored-by: Doug Hoskisson --- BaseClasses.py | 66 +++- docs/entrance randomization.md | 430 ++++++++++++++++++++++++++ entrance_rando.py | 447 ++++++++++++++++++++++++++++ test/general/test_entrance_rando.py | 387 ++++++++++++++++++++++++ 4 files changed, 1324 insertions(+), 6 deletions(-) create mode 100644 docs/entrance randomization.md create mode 100644 entrance_rando.py create mode 100644 test/general/test_entrance_rando.py diff --git a/BaseClasses.py b/BaseClasses.py index e5c187b9117f..e19ba5f7772e 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -19,6 +19,7 @@ import Utils if TYPE_CHECKING: + from entrance_rando import ERPlacementState from worlds import AutoWorld @@ -426,12 +427,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) @@ -717,10 +718,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]] = [] - def __init__(self, parent: MultiWorld): + def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): 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()} @@ -729,6 +731,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(): @@ -763,6 +766,8 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu 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 connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) @@ -788,7 +793,9 @@ def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: if new_region in reachable_regions: blocked_connections.remove(connection) elif connection.can_reach(self): - assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" + if self.allow_partial_entrances and not new_region: + continue + assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) blocked_connections.update(new_region.exits) @@ -808,6 +815,7 @@ def copy(self) -> CollectionState: ret.advancements = self.advancements.copy() ret.path = self.path.copy() ret.locations_checked = self.locations_checked.copy() + ret.allow_partial_entrances = self.allow_partial_entrances for function in self.additional_copy_functions: ret = function(self, ret) return ret @@ -972,6 +980,11 @@ def remove(self, item: Item): self.stale[item.player] = True +class EntranceType(IntEnum): + ONE_WAY = 1 + TWO_WAY = 2 + + class Entrance: access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False @@ -979,19 +992,24 @@ class Entrance: name: str parent_region: Optional[Region] connected_region: Optional[Region] = None + randomization_group: int + randomization_type: EntranceType # LttP specific, TODO: should make a LttPEntrance addresses = None target = None - def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None: + def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, + randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None: self.name = name self.parent_region = parent self.player = player + self.randomization_group = randomization_group + self.randomization_type = randomization_type def can_reach(self, state: CollectionState) -> bool: assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" 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 @@ -1003,6 +1021,32 @@ def connect(self, region: Region, addresses: Any = None, target: Any = None) -> self.addresses = addresses region.entrances.append(self) + def is_valid_source_transition(self, er_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 er_state: The current (partial) state of the ongoing entrance randomization + """ + return self.can_reach(er_state.collection_state) + + def can_connect_to(self, other: Entrance, dead_end: bool, er_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. + + :param other: The proposed Entrance to connect to + :param dead_end: Whether the other entrance considered a dead end by Entrance randomization + :param er_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.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name) + def __repr__(self): multiworld = self.parent_region.multiworld if self.parent_region else None return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' @@ -1152,6 +1196,16 @@ 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) -> List[Entrance]: """ diff --git a/docs/entrance randomization.md b/docs/entrance randomization.md new file mode 100644 index 000000000000..9e3e281bcc31 --- /dev/null +++ b/docs/entrance randomization.md @@ -0,0 +1,430 @@ +# Entrance Randomization + +This document discusses the API and underlying implementation of the generic entrance randomization algorithm +exposed in [entrance_rando.py](/entrance_rando.py). Throughout the doc, entrance randomization is frequently abbreviated +as "ER." + +This doc assumes familiarity with Archipelago's graph logic model. If you don't have a solid understanding of how +regions work, you should start there. + +## Entrance randomization concepts + +### Terminology + +Some important terminology to understand when reading this doc and working with ER is listed below. + +* Entrance rando - sometimes called "room rando," "transition rando," "door rando," or similar, + this is a game mode in which the game map itself is randomized. + In Archipelago, these things are often represented as `Entrance`s in the region graph, so we call it Entrance rando. +* Entrances and exits - entrances are ways into your region, exits are ways out of the region. In code, they are both + represented as `Entrance` objects. In this doc, the terms "entrances" and "exits" will be used in this sense; the + `Entrance` class will always be referenced in a code block with an uppercase E. +* Dead end - a connected group of regions which can never help ER progress. This means that it: + * Is not in any indirect conditions/access rules. + * Has no plando'd or otherwise preplaced progression items, including events. + * Has no randomized exits. +* One way transition - a transition that, in the game, is not safe to reverse through (for example, in Hollow Knight, + some transitions are inaccessible backwards in vanilla and would put you out of bounds). One way transitions are + paired together during randomization to prevent such unsafe game states. Most transitions are not one way. + +### Basic randomization strategy + +The Generic ER algorithm works by using the logic structures you are already familiar with. To give a basic example, +let's assume a toy world is defined with the vanilla region graph modeled below. In this diagram, the smaller boxes +represent regions while the larger boxes represent scenes. Scenes are not an Archipelago concept, the grouping is +purely illustrative. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Lower Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Upper Left Door] <--> AR1 + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> AL2 + BR1 <--> AL1 + AR1 <--> CL1 + CR1 <--> DL1 + DR1 <--> EL1 + CR2 <--> EL2 + + classDef hidden display:none; +``` + +First, the world begins by splitting the `Entrance`s which should be randomized. This is essentially all that has to be +done on the world side; calling the `randomize_entrances` function will do the rest, using your region definitions and +logic to generate a valid world layout by connecting the partially connected edges you've defined. After you have done +that, your region graph might look something like the following diagram. Note how each randomizable entrance/exit pair +(represented as a bidirectional arrow) is disconnected on one end. + +> [!NOTE] +> It is required to use explicit indirect conditions when using Generic ER. Without this restriction, +> Generic ER would have no way to correctly determine that a region may be required in logic, +> leading to significantly higher failure rates due to mis-categorized regions. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Lower Left Door] <--> AR1 + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> T1:::hidden + T2:::hidden <--> AL1 + T3:::hidden <--> AL2 + AR1 <--> T5:::hidden + BR1 <--> T4:::hidden + T6:::hidden <--> CL1 + CR1 <--> T7:::hidden + CR2 <--> T11:::hidden + T8:::hidden <--> DL1 + DR1 <--> T9:::hidden + T10:::hidden <--> EL1 + T12:::hidden <--> EL2 + + classDef hidden display:none; +``` + +From here, you can call the `randomize_entrances` function and Archipelago takes over. Starting from the Menu region, +the algorithm will sweep out to find eligible region exits to randomize. It will then select an eligible target entrance +and connect them, prioritizing giving access to unvisited regions first until all regions are placed. Once the exit has +been connected to the new region, placeholder entrances are deleted. This process is visualized in the diagram below +with the newly connected edge highlighted in red. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Lower Left Door] <--> AR1 + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> CL1 + T2:::hidden <--> AL1 + T3:::hidden <--> AL2 + AR1 <--> T5:::hidden + BR1 <--> T4:::hidden + CR1 <--> T7:::hidden + CR2 <--> T11:::hidden + T8:::hidden <--> DL1 + DR1 <--> T9:::hidden + T10:::hidden <--> EL1 + T12:::hidden <--> EL2 + + classDef hidden display:none; + linkStyle 8 stroke:red,stroke-width:5px; +``` + +This process is then repeated until all disconnected `Entrance`s have been connected or deleted, eventually resulting +in a randomized region layout. + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph startingRoom [Starting Room] + S[Starting Room Right Door] + end + subgraph sceneA [Scene A] + AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] + AL2[Scene A Lower Left Door] <--> AR1 + end + subgraph sceneB [Scene B] + BR1[Scene B Right Door] + end + subgraph sceneC [Scene C] + CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] + CL1 <--> CR2[Scene C Lower Right Door] + end + subgraph sceneD [Scene D] + DL1[Scene D Left Door] <--> DR1[Scene D Right Door] + end + subgraph endingRoom [Ending Room] + EL1[Ending Room Upper Left Door] <--> Victory + EL2[Ending Room Lower Left Door] <--> Victory + end + Menu --> S + S <--> CL1 + AR1 <--> DL1 + BR1 <--> EL2 + CR1 <--> EL1 + CR2 <--> AL1 + DR1 <--> AL2 + + classDef hidden display:none; +``` + +#### ER and minimal accessibility + +In general, even on minimal accessibility, ER will prefer to provide access to as many regions as possible. This is for +2 reasons: +1. Generally, having items spread across the world is going to be a more fun/engaging experience for players than + severely restricting their map. Imagine an ER arrangement with just the start region, the goal region, and exactly + enough locations in between them to get the goal - this may be the intuitive behavior of minimal, or even the desired + behavior in some cases, but it is not a particularly interesting randomizer. +2. Giving access to more of the world will give item fill a higher chance to succeed. + +However, ER will cull unreachable regions and exits if necessary to save the generation of a beaten minimal. + +## Usage + +### Defining entrances to be randomized + +The first step to using generic ER is defining entrances to be randomized. In order to do this, you will need to +leave partially disconnected exits without a `target_region` and partially disconnected entrances without a +`parent_region`. You can do this either by hand using `region.create_exit` and `region.create_er_target`, or you can +create your vanilla region graph and then use `disconnect_entrance_for_randomization` to split the desired edges. +If you're not sure which to use, prefer the latter approach as it will automatically satisfy the requirements for +coupled randomization (discussed in more depth later). + +> [!TIP] +> It's recommended to give your `Entrance`s non-default names when creating them. The default naming scheme is +> `f"{parent_region} -> {target_region}"` which is generally not helpful in an entrance rando context - after all, +> the target region will not be the same as vanilla and regions are often not user-facing anyway. Instead consider names +> that describe the location of the exit, such as "Starting Room Right Door." + +When creating your `Entrance`s you should also set the randomization type and group. One-way `Entrance`s represent +transitions which are impossible to traverse in reverse. All other transitions are two-ways. To ensure that all +transitions can be accessed in the game, one-ways are only randomized with other one-ways and two-ways are only +randomized with other two-ways. You can set whether an `Entrance` is one-way or two-way using the `randomization_type` +attribute. + +`Entrance`s can also set the `randomization_group` attribute to allow for grouping during randomization. This can be +any integer you define and may be based on player options. Some possible use cases for grouping include: +* Directional matching - only match leftward-facing transitions to rightward-facing ones +* Terrain matching - only match water transitions to water transitions and land transitions to land transitions +* Dungeon shuffle - only shuffle entrances within a dungeon/area with each other +* Combinations of the above + +By default, all `Entrance`s are placed in the group 0. An entrance can only be a member of one group, but a given group +may connect to many other groups. + +### Calling generic ER + +Once you have defined all your entrances and exits and connected the Menu region to your region graph, you can call +`randomize_entrances` to perform randomization. + +#### Coupled and uncoupled modes + +In coupled randomization, an entrance placed from A to B guarantees that the reverse placement B to A also exists +(assuming that A and B are both two-way doors). Uncoupled randomization does not make this guarantee. + +When using coupled mode, there are some requirements for how placeholder ER targets for two-ways are named. +`disconnect_entrance_for_randomization` will handle this for you. However, if you opt to create your ER targets and +exits by hand, you will need to ensure that ER targets into a region are named the same as the exit they correspond to. +This allows the randomizer to find and connect the reverse pairing after the first pairing is completed. See the diagram +below for an example of incorrect and correct naming. + +Incorrect target naming: + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph a [" "] + direction TB + target1 + target2 + end + subgraph b [" "] + direction TB + Region + end + Region["Room1"] -->|Room1 Right Door| target1:::hidden + Region --- target2:::hidden -->|Room2 Left Door| Region + + linkStyle 1 stroke:none; + classDef hidden display:none; + style a display:none; + style b display:none; +``` + +Correct target naming: + +```mermaid +%%{init: {"graph": {"defaultRenderer": "elk"}} }%% +graph LR + subgraph a [" "] + direction TB + target1 + target2 + end + subgraph b [" "] + direction TB + Region + end + Region["Room1"] -->|Room1 Right Door| target1:::hidden + Region --- target2:::hidden -->|Room1 Right Door| Region + + linkStyle 1 stroke:none; + classDef hidden display:none; + style a display:none; + style b display:none; +``` + +#### Implementing grouping + +When you created your entrances, you defined the group each entrance belongs to. Now you will have to define how groups +should connect with each other. This is done with the `target_group_lookup` and `preserve_group_order` parameters. +There is also a convenience function `bake_target_group_lookup` which can help to prepare group lookups when more +complex group mapping logic is needed. Some recipes for `target_group_lookup` are presented here. + +For the recipes below, assume the following groups (if the syntax used here is unfamiliar to you, "bit masking" and +"bitwise operators" would be the terms to search for): +```python +class Groups(IntEnum): + # Directions + LEFT = 1 + RIGHT = 2 + TOP = 3 + BOTTOM = 4 + DOOR = 5 + # Areas + FIELD = 1 << 3 + CAVE = 2 << 3 + MOUNTAIN = 3 << 3 + # Bitmasks + DIRECTION_MASK = FIELD - 1 + AREA_MASK = ~0 << 3 +``` + +Directional matching: +```python +direction_matching_group_lookup = { + # with preserve_group_order = False, pair a left transition to either a right transition or door randomly + # with preserve_group_order = True, pair a left transition to a right transition, or else a door if no + # viable right transitions remain + Groups.LEFT: [Groups.RIGHT, Groups.DOOR], + # ... +} +``` + +Terrain matching or dungeon shuffle: +```python +def randomize_within_same_group(group: int) -> List[int]: + return [group] +identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group) +``` + +Directional + area shuffle: +```python +def get_target_groups(group: int) -> List[int]: + # example group: LEFT | CAVE + # example result: [RIGHT | CAVE, DOOR | CAVE] + direction = group & Groups.DIRECTION_MASK + area = group & Groups.AREA_MASK + return [pair_direction | area for pair_direction in direction_matching_group_lookup[direction]] +target_group_lookup = bake_target_group_lookup(world, get_target_groups) +``` + +#### When to call `randomize_entrances` + +The short answer is that you will almost always want to do ER in `pre_fill`. For more information why, continue reading. + +ER begins by collecting the entire item pool and then uses your access rules to try and prevent some kinds of failures. +This means 2 things about when you can call ER: +1. You must supply your item pool before calling ER, or call ER before setting any rules which require items. +2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules + and create your events before you call ER if you want to guarantee a correct output. + +If the conditions above are met, you could theoretically do ER as early as `create_regions`. However, plando is also +a consideration. Since item plando happens between `set_rules` and `pre_fill` and modifies the item pool, doing ER +in `pre_fill` is the only way to account for placements made by item plando, otherwise you risk impossible seeds or +generation failures. Obviously, if your world implements entrance plando, you will likely want to do that before ER as +well. + +#### Informing your client about randomized entrances + +`randomize_entrances` returns the completed `ERPlacementState`. The `pairings` attribute contains a list of the +created placements by name which can be used to populate slot data. + +### Imposing custom constraints on randomization + +Generic ER is, as the name implies, generic! That means that your world may have some use case which is not covered by +the ER implementation. To solve this, you can create a custom `Entrance` class which provides custom implementations +for `is_valid_source_transition` and `can_connect_to`. These allow arbitrary constraints to be implemented on +randomization, for instance helping to prevent restrictive sphere 1s or ensuring a maximum distance from a "hub" region. + +> [!IMPORTANT] +> When implementing these functions, make sure to use `super().is_valid_source_transition` and `super().can_connect_to` +> as part of your implementation. Otherwise ER may behave unexpectedly. + +## Implementation details + +This section is a medium-level explainer of the implementation of ER for those who don't want to decipher the code. +However, a basic understanding of the mechanics of `fill_restrictive` will be helpful as many of the underlying +algorithms are shared + +ER uses a forward fill approach to create the region layout. First, ER collects `all_state` and performs a region sweep +from Menu, similar to fill. ER then proceeds in stages to complete the randomization: +1. Attempt to connect all non-dead-end regions, prioritizing access to unseen regions so there will always be new exits + to pair off. +2. Attempt to connect all dead-end regions, so that all regions will be placed +3. Connect all remaining dangling edges now that all regions are placed. + 1. Connect any other dead end entrances (e.g. second entrances to the same dead end regions). + 2. Connect all remaining non-dead-ends amongst each other. + +The process for each connection will do the following: +1. Select a randomizable exit of a reachable region which is a valid source transition. +2. Get its group and check `target_group_lookup` to determine which groups are valid targets. +3. Look up ER targets from those groups and find one which is valid according to `can_connect_to` +4. Connect the source exit to the target's target_region and delete the target. + * In stage 1, before placing the last valid source transition, an additional speculative sweep is performed to ensure + that there will be an available exit after the placement so randomization can continue. +5. If it's coupled mode, find the reverse exit and target by name and connect them as well. +6. Sweep to update reachable regions. +7. Call the `on_connect` callback. + +This process repeats until the stage is complete, no valid source transition is found, or no valid target transition is +found for any source transition. Unlike fill, there is no attempt made to save a failed randomization. \ No newline at end of file diff --git a/entrance_rando.py b/entrance_rando.py new file mode 100644 index 000000000000..5aa16fa0bb06 --- /dev/null +++ b/entrance_rando.py @@ -0,0 +1,447 @@ +import itertools +import logging +import random +import time +from collections import deque +from collections.abc import Callable, Iterable + +from BaseClasses import CollectionState, Entrance, Region, EntranceType +from Options import Accessibility +from worlds.AutoWorld import World + + +class EntranceRandomizationError(RuntimeError): + pass + + +class EntranceLookup: + class GroupLookup: + _lookup: dict[int, list[Entrance]] + + def __init__(self): + self._lookup = {} + + def __len__(self): + return sum(map(len, self._lookup.values())) + + def __bool__(self): + return bool(self._lookup) + + def __getitem__(self, item: int) -> list[Entrance]: + return self._lookup.get(item, []) + + def __iter__(self): + return itertools.chain.from_iterable(self._lookup.values()) + + def __repr__(self): + return str(self._lookup) + + def add(self, entrance: Entrance) -> None: + self._lookup.setdefault(entrance.randomization_group, []).append(entrance) + + def remove(self, entrance: Entrance) -> None: + group = self._lookup[entrance.randomization_group] + group.remove(entrance) + if not group: + del self._lookup[entrance.randomization_group] + + dead_ends: GroupLookup + others: GroupLookup + _random: random.Random + _expands_graph_cache: dict[Entrance, bool] + _coupled: bool + + def __init__(self, rng: random.Random, coupled: bool): + self.dead_ends = EntranceLookup.GroupLookup() + self.others = EntranceLookup.GroupLookup() + self._random = rng + self._expands_graph_cache = {} + self._coupled = coupled + + def _can_expand_graph(self, entrance: Entrance) -> bool: + """ + Checks whether an entrance is able to expand the region graph, either by + providing access to randomizable exits or by granting access to items or + regions used in logic conditions. + + :param entrance: A randomizable (no parent) region entrance + """ + # we've seen this, return cached result + if entrance in self._expands_graph_cache: + return self._expands_graph_cache[entrance] + + visited = set() + q: deque[Region] = deque() + q.append(entrance.connected_region) + + while q: + region = q.popleft() + visited.add(region) + + # check if the region itself is progression + if region in region.multiworld.indirect_connections: + self._expands_graph_cache[entrance] = True + return True + + # check if any placed locations are progression + for loc in region.locations: + if loc.advancement: + self._expands_graph_cache[entrance] = True + return True + + # check if there is a randomized exit out (expands the graph directly) or else search any connected + # regions to see if they are/have progression + for exit_ in region.exits: + # randomizable exits which are not reverse of the incoming entrance. + # uncoupled mode is an exception because in this case going back in the door you just came in could + # actually lead somewhere new + if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name): + self._expands_graph_cache[entrance] = True + return True + elif exit_.connected_region and exit_.connected_region not in visited: + q.append(exit_.connected_region) + + self._expands_graph_cache[entrance] = False + return False + + def add(self, entrance: Entrance) -> None: + lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends + lookup.add(entrance) + + def remove(self, entrance: Entrance) -> None: + lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends + lookup.remove(entrance) + + def get_targets( + self, + groups: Iterable[int], + 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 + + def __len__(self): + return len(self.dead_ends) + len(self.others) + + +class ERPlacementState: + """The state of an ongoing or completed entrance randomization""" + placements: list[Entrance] + """The list of randomized Entrance objects which have been connected successfully""" + pairings: list[tuple[str, str]] + """A list of pairings of connected entrance names, of the form (source_exit, target_entrance)""" + world: World + """The world which is having its entrances randomized""" + collection_state: CollectionState + """The CollectionState backing the entrance randomization logic""" + coupled: bool + """Whether entrance randomization is operating in coupled mode""" + + def __init__(self, world: World, coupled: bool): + self.placements = [] + self.pairings = [] + self.world = world + self.coupled = coupled + self.collection_state = world.multiworld.get_all_state(False, True) + + @property + def placed_regions(self) -> set[Region]: + return self.collection_state.reachable_regions[self.world.player] + + def find_placeable_exits(self, check_validity: bool) -> list[Entrance]: + if check_validity: + 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)] + else: + # this is on a beaten minimal attempt, so any exit anywhere is fair game + placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player) + for ex in region.exits if not ex.connected_region] + 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 test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool: + copied_state = self.collection_state.copy() + # simulated connection. A real connection is unsafe because the region graph is shallow-copied and would + # propagate back to the real multiworld. + copied_state.reachable_regions[self.world.player].add(target_entrance.connected_region) + copied_state.blocked_connections[self.world.player].remove(source_exit) + copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits) + copied_state.update_reachable_regions(self.world.player) + copied_state.sweep_for_advancements() + # test that at there are newly reachable randomized exits that are ACTUALLY reachable + available_randomized_exits = copied_state.blocked_connections[self.world.player] + for _exit in available_randomized_exits: + if _exit.connected_region: + continue + # ignore the source exit, and, if coupled, the reverse exit. They're not actually new + if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name): + continue + # technically this should be is_valid_source_transition, but that may rely on side effects from + # on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would + # not want them to persist). can_reach is a close enough approximation most of the time. + if _exit.can_reach(copied_state): + return True + return False + + def connect( + self, + source_exit: Entrance, + target_entrance: Entrance + ) -> tuple[list[Entrance], list[Entrance]]: + """ + Connects a source exit to a target entrance in the graph, accounting for coupling + + :returns: The newly placed exits and 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.randomization_type == 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 [source_exit, reverse_exit], [target_entrance, reverse_entrance] + return [source_exit], [target_entrance] + + +def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], list[int]]) \ + -> dict[int, list[int]]: + """ + Applies a transformation to all known entrance groups on randomizable exists to build a group lookup table. + + :param world: Your World instance + :param get_target_groups: Function to call that returns the groups that a specific group type is allowed to + connect to + """ + unique_groups = { entrance.randomization_group for entrance in world.multiworld.get_entrances(world.player) + if entrance.parent_region and not entrance.connected_region } + return { group: get_target_groups(group) for group in unique_groups } + + +def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None: + """ + Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization + in randomize_entrances. This should be done after setting the type and group of the entrance. + + :param entrance: The entrance which will be disconnected in preparation for randomization. + :param target_group: The group to assign to the created ER target. If not specified, the group from + the original entrance will be copied. + """ + 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.randomization_type == 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.randomization_type = entrance.randomization_type + target.randomization_group = target_group or entrance.randomization_group + + +def randomize_entrances( + world: World, + coupled: bool, + target_group_lookup: dict[int, list[int]], + preserve_group_order: bool = False, + er_targets: list[Entrance] | None = None, + exits: list[Entrance] | None = None, + on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None +) -> ERPlacementState: + """ + Randomizes Entrances for a single world in the multiworld. + + :param world: Your World instance + :param coupled: Whether connected entrances should be coupled to go in both directions + :param target_group_lookup: Map from each group to a list of the groups that it can be connect to. Every group + used on an exit must be provided and must map to at least one other group. The default + group is 0. + :param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups + :param er_targets: The list of ER targets (Entrance objects with no parent region) to use for randomization. + Remember to be deterministic! If not provided, automatically discovers all valid targets + in your world. + :param exits: The list of exits (Entrance objects with no target region) to use for randomization. + Remember to be deterministic! If not provided, automatically discovers all valid exits in your world. + :param on_connect: A callback function which allows specifying side effects after a placement is completed + successfully and the underlying collection state has been updated. + """ + if not world.explicit_indirect_conditions: + raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order " + + "to correctly analyze whether dead end regions can be required in logic.") + + start_time = time.perf_counter() + er_state = ERPlacementState(world, coupled) + entrance_lookup = EntranceLookup(world.random, coupled) + # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility + perform_validity_check = True + + def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None: + placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance) + # remove the placed targets from consideration + for entrance in removed_entrances: + entrance_lookup.remove(entrance) + # propagate new connections + er_state.collection_state.update_reachable_regions(world.player) + er_state.collection_state.sweep_for_advancements() + if on_connect: + on_connect(er_state, placed_exits) + + def find_pairing(dead_end: bool, require_new_exits: bool) -> bool: + nonlocal perform_validity_check + placeable_exits = er_state.find_placeable_exits(perform_validity_check) + for source_exit in placeable_exits: + target_groups = target_group_lookup[source_exit.randomization_group] + for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): + # when requiring new exits, ideally we would like to make it so that every placement increases + # (or keeps the same number of) reachable exits. The goal is to continue to expand the search space + # so that we do not crash. In the interest of performance and bias reduction, generally, just checking + # that we are going to a new region is a good approximation. however, we should take extra care on the + # very last exit and check whatever exits we open up are functionally accessible. + # this requirement can be ignored on a beaten minimal, islands are no issue there. + exit_requirement_satisfied = (not perform_validity_check or not require_new_exits + or target_entrance.connected_region not in er_state.placed_regions) + needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check + and len(placeable_exits) == 1) + if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state): + if (needs_speculative_sweep + and not er_state.test_speculative_connection(source_exit, target_entrance)): + continue + do_placement(source_exit, target_entrance) + return True + else: + # no source exits had any valid target so this stage is deadlocked. retries may be implemented if early + # deadlocking is a frequent issue. + lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others + + # 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_exits: + if all(e.connected_region in er_state.placed_regions for e in lookup): + return False + + # if we're on minimal accessibility and can guarantee the game is beatable, + # we can prevent a failure by bypassing future validity checks. this check may be + # expensive; fortunately we only have to do it once + if perform_validity_check and world.options.accessibility == Accessibility.option_minimal \ + and world.multiworld.has_beaten_game(er_state.collection_state, world.player): + # ensure that we have enough locations to place our progression + accessible_location_count = 0 + prog_item_count = sum(er_state.collection_state.prog_items[world.player].values()) + # short-circuit location checking in this case + if prog_item_count == 0: + return True + for region in er_state.placed_regions: + for loc in region.locations: + if loc.can_reach(er_state.collection_state): + accessible_location_count += 1 + if accessible_location_count >= prog_item_count: + perform_validity_check = False + # pretend that this was successful to retry the current stage + return True + + unplaced_entrances = [entrance for region in world.multiworld.get_regions(world.player) + for entrance in region.entrances if not entrance.parent_region] + unplaced_exits = [exit_ for region in world.multiworld.get_regions(world.player) + for exit_ in region.exits if not exit_.connected_region] + entrance_kind = "dead ends" if dead_end else "non-dead ends" + region_access_requirement = "requires" if require_new_exits else "does not require" + raise EntranceRandomizationError( + f"None of the available entrances are valid targets for the available exits.\n" + f"Randomization stage is placing {entrance_kind} and {region_access_requirement} " + f"new region/exit access by default\n" + f"Placeable entrances: {lookup}\n" + f"Placeable exits: {placeable_exits}\n" + f"All unplaced entrances: {unplaced_entrances}\n" + f"All unplaced exits: {unplaced_exits}") + + if not er_targets: + er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player) + for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name) + if not exits: + exits = sorted([ex for region in world.multiworld.get_regions(world.player) + for ex in region.exits if not ex.connected_region], key=lambda x: x.name) + if len(er_targets) != len(exits): + raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of " + f"entrances ({len(er_targets)}) and exits ({len(exits)}.") + for entrance in er_targets: + 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(dead_end=False, require_new_exits=True): + break + # stage 2 - try to place all the dead-end entrances + while entrance_lookup.dead_ends: + if not find_pairing(dead_end=True, require_new_exits=True): + break + # stage 3 - all the regions should be placed at this point. We now need to connect dangling edges + # stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions) + # doing this before the non-dead-ends is important to ensure there are enough connections to + # go around + while entrance_lookup.dead_ends: + find_pairing(dead_end=True, require_new_exits=False) + # stage 3b - tie all the other loose ends connecting visited regions to each other + while entrance_lookup.others: + find_pairing(dead_end=False, require_new_exits=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/test/general/test_entrance_rando.py b/test/general/test_entrance_rando.py new file mode 100644 index 000000000000..efbcf7df4636 --- /dev/null +++ b/test/general/test_entrance_rando.py @@ -0,0 +1,387 @@ +import unittest +from enum import IntEnum + +from BaseClasses import Region, EntranceType, MultiWorld, Entrance +from entrance_rando import disconnect_entrance_for_randomization, randomize_entrances, EntranceRandomizationError, \ + ERPlacementState, EntranceLookup, bake_target_group_lookup +from Options import Accessibility +from test.general import generate_test_multiworld, generate_locations, generate_items +from worlds.generic.Rules import set_rule + + +class ERTestGroups(IntEnum): + LEFT = 1 + RIGHT = 2 + TOP = 3 + BOTTOM = 4 + + +directionally_matched_group_lookup = { + ERTestGroups.LEFT: [ERTestGroups.RIGHT], + ERTestGroups.RIGHT: [ERTestGroups.LEFT], + ERTestGroups.TOP: [ERTestGroups.BOTTOM], + ERTestGroups.BOTTOM: [ERTestGroups.TOP] +} + + +def generate_entrance_pair(region: Region, name_suffix: str, group: int): + lx = region.create_exit(region.name + name_suffix) + lx.randomization_group = group + lx.randomization_type = EntranceType.TWO_WAY + le = region.create_er_target(region.name + name_suffix) + le.randomization_group = group + le.randomization_type = EntranceType.TWO_WAY + + +def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0, + region_type: type[Region] = Region): + """ + Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each + region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the + bottom right + """ + for row in range(grid_side_length): + for col in range(grid_side_length): + index = row * grid_side_length + col + name = f"region{index}" + region = region_type(name, 1, multiworld) + multiworld.regions.append(region) + generate_locations(region_size, 1, region=region, tag=f"_{name}") + + if row == 0 and col == 0: + multiworld.get_region("Menu", 1).connect(region) + if col != 0: + generate_entrance_pair(region, "_left", ERTestGroups.LEFT) + if col != grid_side_length - 1: + generate_entrance_pair(region, "_right", ERTestGroups.RIGHT) + if row != 0: + generate_entrance_pair(region, "_top", ERTestGroups.TOP) + if row != grid_side_length - 1: + generate_entrance_pair(region, "_bottom", ERTestGroups.BOTTOM) + + +class TestEntranceLookup(unittest.TestCase): + def test_shuffled_targets(self): + """tests that get_targets shuffles targets between groups when requested""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + for entrance in er_targets: + lookup.add(entrance) + + retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], + False, False) + prev = None + group_order = [prev := group.randomization_group for group in retrieved_targets + if prev != group.randomization_group] + # technically possible that group order may not be shuffled, by some small chance, on some seeds. but generally + # a shuffled list should alternate more frequently which is the desired behavior here + self.assertGreater(len(group_order), 2) + + + def test_ordered_targets(self): + """tests that get_targets does not shuffle targets between groups when requested""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) + er_targets = [entrance for region in multiworld.get_regions(1) + for entrance in region.entrances if not entrance.parent_region] + for entrance in er_targets: + lookup.add(entrance) + + retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], + False, True) + prev = None + group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group] + self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order) + + +class TestBakeTargetGroupLookup(unittest.TestCase): + def test_lookup_generation(self): + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + world = multiworld.worlds[1] + expected = { + ERTestGroups.LEFT: [-ERTestGroups.LEFT], + ERTestGroups.RIGHT: [-ERTestGroups.RIGHT], + ERTestGroups.TOP: [-ERTestGroups.TOP], + ERTestGroups.BOTTOM: [-ERTestGroups.BOTTOM] + } + actual = bake_target_group_lookup(world, lambda g: [-g]) + self.assertEqual(expected, actual) + + +class TestDisconnectForRandomization(unittest.TestCase): + def test_disconnect_default_2way(self): + multiworld = generate_test_multiworld() + r1 = Region("r1", 1, multiworld) + r2 = Region("r2", 1, multiworld) + e = r1.create_exit("e") + e.randomization_type = EntranceType.TWO_WAY + e.randomization_group = 1 + e.connect(r2) + + disconnect_entrance_for_randomization(e) + + self.assertIsNone(e.connected_region) + self.assertEqual([], r2.entrances) + + self.assertEqual(1, len(r1.exits)) + self.assertEqual(e, r1.exits[0]) + + self.assertEqual(1, len(r1.entrances)) + self.assertIsNone(r1.entrances[0].parent_region) + self.assertEqual("e", r1.entrances[0].name) + self.assertEqual(EntranceType.TWO_WAY, r1.entrances[0].randomization_type) + self.assertEqual(1, r1.entrances[0].randomization_group) + + def test_disconnect_default_1way(self): + multiworld = generate_test_multiworld() + r1 = Region("r1", 1, multiworld) + r2 = Region("r2", 1, multiworld) + e = r1.create_exit("e") + e.randomization_type = EntranceType.ONE_WAY + e.randomization_group = 1 + e.connect(r2) + + disconnect_entrance_for_randomization(e) + + self.assertIsNone(e.connected_region) + self.assertEqual([], r1.entrances) + + self.assertEqual(1, len(r1.exits)) + self.assertEqual(e, r1.exits[0]) + + self.assertEqual(1, len(r2.entrances)) + self.assertIsNone(r2.entrances[0].parent_region) + self.assertEqual("r2", r2.entrances[0].name) + self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) + self.assertEqual(1, r2.entrances[0].randomization_group) + + def test_disconnect_uses_alternate_group(self): + multiworld = generate_test_multiworld() + r1 = Region("r1", 1, multiworld) + r2 = Region("r2", 1, multiworld) + e = r1.create_exit("e") + e.randomization_type = EntranceType.ONE_WAY + e.randomization_group = 1 + e.connect(r2) + + disconnect_entrance_for_randomization(e, 2) + + self.assertIsNone(e.connected_region) + self.assertEqual([], r1.entrances) + + self.assertEqual(1, len(r1.exits)) + self.assertEqual(e, r1.exits[0]) + + self.assertEqual(1, len(r2.entrances)) + self.assertIsNone(r2.entrances[0].parent_region) + self.assertEqual("r2", r2.entrances[0].name) + self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) + self.assertEqual(2, r2.entrances[0].randomization_group) + + +class TestRandomizeEntrances(unittest.TestCase): + def test_determinism(self): + """tests that the same output is produced for the same input""" + multiworld1 = generate_test_multiworld() + generate_disconnected_region_grid(multiworld1, 5) + multiworld2 = generate_test_multiworld() + generate_disconnected_region_grid(multiworld2, 5) + + result1 = randomize_entrances(multiworld1.worlds[1], False, directionally_matched_group_lookup) + result2 = randomize_entrances(multiworld2.worlds[1], False, directionally_matched_group_lookup) + self.assertEqual(result1.pairings, result2.pairings) + for e1, e2 in zip(result1.placements, result2.placements): + self.assertEqual(e1.name, e2.name) + self.assertEqual(e1.parent_region.name, e1.parent_region.name) + self.assertEqual(e1.connected_region.name, e2.connected_region.name) + + def test_all_entrances_placed(self): + """tests that all entrances and exits were placed, all regions are connected, and no dangling edges exist""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + + self.assertEqual([], [entrance for region in multiworld.get_regions() + for entrance in region.entrances if not entrance.parent_region]) + self.assertEqual([], [exit_ for region in multiworld.get_regions() + for exit_ in region.exits if not exit_.connected_region]) + # 5x5 grid + menu + self.assertEqual(26, len(result.placed_regions)) + self.assertEqual(80, len(result.pairings)) + self.assertEqual(80, len(result.placements)) + + def test_coupling(self): + """tests that in coupled mode, all 2 way transitions have an inverse""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + seen_placement_count = 0 + + def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]): + nonlocal seen_placement_count + seen_placement_count += len(placed_entrances) + self.assertEqual(2, len(placed_entrances)) + self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region) + self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region) + + result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup, + on_connect=verify_coupled) + # if we didn't visit every placement the verification on_connect doesn't really mean much + self.assertEqual(len(result.placements), seen_placement_count) + + def test_uncoupled(self): + """tests that in uncoupled mode, no transitions have an (intentional) inverse""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + seen_placement_count = 0 + + def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]): + nonlocal seen_placement_count + seen_placement_count += len(placed_entrances) + self.assertEqual(1, len(placed_entrances)) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup, + on_connect=verify_uncoupled) + # if we didn't visit every placement the verification on_connect doesn't really mean much + self.assertEqual(len(result.placements), seen_placement_count) + + def test_oneway_twoway_pairing(self): + """tests that 1 ways are only paired to 1 ways and 2 ways are only paired to 2 ways""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + region26 = Region("region26", 1, multiworld) + multiworld.regions.append(region26) + for index, region in enumerate(["region4", "region20", "region24"]): + x = multiworld.get_region(region, 1).create_exit(f"{region}_bottom_1way") + x.randomization_type = EntranceType.ONE_WAY + x.randomization_group = ERTestGroups.BOTTOM + e = region26.create_er_target(f"region26_top_1way{index}") + e.randomization_type = EntranceType.ONE_WAY + e.randomization_group = ERTestGroups.TOP + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + for exit_name, entrance_name in result.pairings: + # we have labeled our entrances in such a way that all the 1 way entrances have 1way in the name, + # so test for that since the ER target will have been discarded + if "1way" in exit_name: + self.assertIn("1way", entrance_name) + + def test_group_constraints_satisfied(self): + """tests that all grouping constraints are satisfied""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + for exit_name, entrance_name in result.pairings: + # we have labeled our entrances in such a way that all the entrances contain their group in the name + # so test for that since the ER target will have been discarded + if "top" in exit_name: + self.assertIn("bottom", entrance_name) + if "bottom" in exit_name: + self.assertIn("top", entrance_name) + if "left" in exit_name: + self.assertIn("right", entrance_name) + if "right" in exit_name: + self.assertIn("left", entrance_name) + + def test_minimal_entrance_rando(self): + """tests that entrance randomization can complete with minimal accessibility and unreachable exits""" + multiworld = generate_test_multiworld() + multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) + multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1) + generate_disconnected_region_grid(multiworld, 5, 1) + prog_items = generate_items(10, 1, True) + multiworld.itempool += prog_items + filler_items = generate_items(15, 1, False) + multiworld.itempool += filler_items + e = multiworld.get_entrance("region1_right", 1) + set_rule(e, lambda state: False) + + randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + + self.assertEqual([], [entrance for region in multiworld.get_regions() + for entrance in region.entrances if not entrance.parent_region]) + self.assertEqual([], [exit_ for region in multiworld.get_regions() + for exit_ in region.exits if not exit_.connected_region]) + + def test_restrictive_region_requirement_does_not_fail(self): + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 2, 1) + + region = Region("region4", 1, multiworld) + multiworld.regions.append(region) + generate_entrance_pair(multiworld.get_region("region0", 1), "_right2", ERTestGroups.RIGHT) + generate_entrance_pair(region, "_left", ERTestGroups.LEFT) + + blocked_exits = ["region1_left", "region1_bottom", + "region2_top", "region2_right", + "region3_left", "region3_top"] + for exit_name in blocked_exits: + blocked_exit = multiworld.get_entrance(exit_name, 1) + blocked_exit.access_rule = lambda state: state.can_reach_region("region4", 1) + multiworld.register_indirect_condition(region, blocked_exit) + + result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup) + # verifying that we did in fact place region3 adjacent to region0 to unblock all the other connections + # (and implicitly, that ER didn't fail) + self.assertTrue(("region0_right", "region4_left") in result.pairings + or ("region0_right2", "region4_left") in result.pairings) + + def test_fails_when_mismatched_entrance_and_exit_count(self): + """tests that entrance randomization fast-fails if the input exit and entrance count do not match""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + multiworld.get_region("region1", 1).create_exit("extra") + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) + + def test_fails_when_some_unreachable_exit(self): + """tests that entrance randomization fails if an exit is never reachable (non-minimal accessibility)""" + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5) + e = multiworld.get_entrance("region1_right", 1) + set_rule(e, lambda state: False) + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) + + def test_fails_when_some_unconnectable_exit(self): + """tests that entrance randomization fails if an exit can't be made into a valid placement (non-minimal)""" + class CustomEntrance(Entrance): + def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool: + if other.name == "region1_right": + return False + + class CustomRegion(Region): + entrance_type = CustomEntrance + + multiworld = generate_test_multiworld() + generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion) + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) + + def test_minimal_er_fails_when_not_enough_locations_to_fit_progression(self): + """ + tests that entrance randomization fails in minimal accessibility if there are not enough locations + available to place all progression items locally + """ + multiworld = generate_test_multiworld() + multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) + multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1) + generate_disconnected_region_grid(multiworld, 5, 1) + prog_items = generate_items(30, 1, True) + multiworld.itempool += prog_items + e = multiworld.get_entrance("region1_right", 1) + set_rule(e, lambda state: False) + + self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, + directionally_matched_group_lookup) From 3bcc86f5391ea00d220bf6bf094a4a08801b162b Mon Sep 17 00:00:00 2001 From: Kory Dondzila Date: Fri, 27 Dec 2024 15:07:55 -0500 Subject: [PATCH 12/38] Shivers: Add events and fix require puzzle hints logic (#4018) * Adds some events, renames things, fails for many players. * Adds entrance rules for requires hints. * Cleanup and add goal item. * Cleanup. * Add additional rule. * Event and regions additions. * Updates from merge. * Adds collect behavior option. * Fix missing generator location. * Fix whitespace and optimize imports. * Switch location order back. * Add name replacement for storage. * Fix test failure. * Improve puzzle hints required. * Add missing locations and cleanup indirect conditions. * Fix naming. * PR feedback. * Missed comment. * Cleanup imports, use strings for option equivalence, and update option description. * Fix rule. * Create rolling buffer goal items and remove goal items and location from default options. * Cleanup. * Removes dateutil. * Fixes Subterranean World information plaque. --- docs/CODEOWNERS | 2 +- worlds/shivers/Constants.py | 20 +- worlds/shivers/Items.py | 306 +++++++++++-------- worlds/shivers/Options.py | 100 ++++++- worlds/shivers/Rules.py | 315 ++++++++++++-------- worlds/shivers/__init__.py | 264 +++++++++------- worlds/shivers/data/excluded_locations.json | 2 +- worlds/shivers/data/locations.json | 144 +++++---- worlds/shivers/data/regions.json | 88 +++--- worlds/shivers/docs/en_Shivers.md | 3 +- 10 files changed, 783 insertions(+), 461 deletions(-) diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index d58207806743..1d70531e9974 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -152,7 +152,7 @@ /worlds/saving_princess/ @LeonarthCG # Shivers -/worlds/shivers/ @GodlFire +/worlds/shivers/ @GodlFire @korydondzila # A Short Hike /worlds/shorthike/ @chandler05 @BrandenEK diff --git a/worlds/shivers/Constants.py b/worlds/shivers/Constants.py index 95b3c2d56ad9..9b7f3dcebc4f 100644 --- a/worlds/shivers/Constants.py +++ b/worlds/shivers/Constants.py @@ -1,17 +1,25 @@ -import os import json +import os import pkgutil +from datetime import datetime + def load_data_file(*args) -> dict: fname = "/".join(["data", *args]) return json.loads(pkgutil.get_data(__name__, fname).decode()) -location_id_offset: int = 27000 -location_info = load_data_file("locations.json") -location_name_to_id = {name: location_id_offset + index \ - for index, name in enumerate(location_info["all_locations"])} +def relative_years_from_today(dt2: datetime) -> int: + today = datetime.now() + years = today.year - dt2.year + if today.month < dt2.month or (today.month == dt2.month and today.day < dt2.day): + years -= 1 + return years -exclusion_info = load_data_file("excluded_locations.json") +location_id_offset: int = 27000 +location_info = load_data_file("locations.json") +location_name_to_id = {name: location_id_offset + index for index, name in enumerate(location_info["all_locations"])} +exclusion_info = load_data_file("excluded_locations.json") region_info = load_data_file("regions.json") +years_since_sep_30_1980 = relative_years_from_today(datetime.fromisoformat("1980-09-30")) diff --git a/worlds/shivers/Items.py b/worlds/shivers/Items.py index 10d234d450bb..a60bad17b8ed 100644 --- a/worlds/shivers/Items.py +++ b/worlds/shivers/Items.py @@ -1,132 +1,198 @@ +import enum +from typing import NamedTuple, Optional + from BaseClasses import Item, ItemClassification -import typing +from . import Constants + class ShiversItem(Item): game: str = "Shivers" -class ItemData(typing.NamedTuple): - code: int - type: str + +class ItemType(enum.Enum): + POT = "pot" + POT_COMPLETE = "pot-complete" + POT_DUPLICATE = "pot-duplicate" + POT_COMPELTE_DUPLICATE = "pot-complete-duplicate" + KEY = "key" + KEY_OPTIONAL = "key-optional" + ABILITY = "ability" + FILLER = "filler" + IXUPI_AVAILABILITY = "ixupi-availability" + GOAL = "goal" + + +class ItemData(NamedTuple): + code: Optional[int] + type: ItemType classification: ItemClassification = ItemClassification.progression + SHIVERS_ITEM_ID_OFFSET = 27000 +# To allow for an item with a name that changes over time (once a year) +# while keeping the id unique we can generate a small range of them. +goal_items = { + f"Mt. Pleasant Tribune: {Constants.years_since_sep_30_1980 + year_offset} year Old Mystery Solved!": ItemData( + SHIVERS_ITEM_ID_OFFSET + 100 + Constants.years_since_sep_30_1980 + year_offset, ItemType.GOAL + ) for year_offset in range(-1, 2) +} + item_table = { - #Pot Pieces - "Water Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 0, "pot"), - "Wax Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 1, "pot"), - "Ash Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 2, "pot"), - "Oil Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 3, "pot"), - "Cloth Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 4, "pot"), - "Wood Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 5, "pot"), - "Crystal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 6, "pot"), - "Lightning Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 7, "pot"), - "Sand Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 8, "pot"), - "Metal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 9, "pot"), - "Water Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 10, "pot"), - "Wax Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 11, "pot"), - "Ash Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 12, "pot"), - "Oil Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 13, "pot"), - "Cloth Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 14, "pot"), - "Wood Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 15, "pot"), - "Crystal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 16, "pot"), - "Lightning Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 17, "pot"), - "Sand Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 18, "pot"), - "Metal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 19, "pot"), - "Water Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 20, "pot_type2"), - "Wax Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 21, "pot_type2"), - "Ash Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 22, "pot_type2"), - "Oil Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 23, "pot_type2"), - "Cloth Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 24, "pot_type2"), - "Wood Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 25, "pot_type2"), - "Crystal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 26, "pot_type2"), - "Lightning Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 27, "pot_type2"), - "Sand Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 28, "pot_type2"), - "Metal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 29, "pot_type2"), - - #Keys - "Key for Office Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 30, "key"), - "Key for Bedroom Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 31, "key"), - "Key for Three Floor Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 32, "key"), - "Key for Workshop": ItemData(SHIVERS_ITEM_ID_OFFSET + 33, "key"), - "Key for Office": ItemData(SHIVERS_ITEM_ID_OFFSET + 34, "key"), - "Key for Prehistoric Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 35, "key"), - "Key for Greenhouse Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 36, "key"), - "Key for Ocean Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 37, "key"), - "Key for Projector Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 38, "key"), - "Key for Generator Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 39, "key"), - "Key for Egypt Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 40, "key"), - "Key for Library Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 41, "key"), - "Key for Shaman Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 42, "key"), - "Key for UFO Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 43, "key"), - "Key for Torture Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 44, "key"), - "Key for Puzzle Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 45, "key"), - "Key for Bedroom": ItemData(SHIVERS_ITEM_ID_OFFSET + 46, "key"), - "Key for Underground Lake Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 47, "key"), - "Key for Janitor Closet": ItemData(SHIVERS_ITEM_ID_OFFSET + 48, "key"), - "Key for Front Door": ItemData(SHIVERS_ITEM_ID_OFFSET + 49, "key-optional"), - - #Abilities - "Crawling": ItemData(SHIVERS_ITEM_ID_OFFSET + 50, "ability"), - - #Event Items - "Victory": ItemData(SHIVERS_ITEM_ID_OFFSET + 60, "victory"), - - #Duplicate pot pieces for fill_Restrictive - "Water Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 70, "potduplicate"), - "Wax Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 71, "potduplicate"), - "Ash Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 72, "potduplicate"), - "Oil Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 73, "potduplicate"), - "Cloth Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 74, "potduplicate"), - "Wood Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 75, "potduplicate"), - "Crystal Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 76, "potduplicate"), - "Lightning Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 77, "potduplicate"), - "Sand Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 78, "potduplicate"), - "Metal Pot Bottom DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 79, "potduplicate"), - "Water Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 80, "potduplicate"), - "Wax Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 81, "potduplicate"), - "Ash Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 82, "potduplicate"), - "Oil Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 83, "potduplicate"), - "Cloth Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 84, "potduplicate"), - "Wood Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 85, "potduplicate"), - "Crystal Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 86, "potduplicate"), - "Lightning Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 87, "potduplicate"), - "Sand Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 88, "potduplicate"), - "Metal Pot Top DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 89, "potduplicate"), - "Water Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 140, "potduplicate_type2"), - "Wax Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 141, "potduplicate_type2"), - "Ash Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 142, "potduplicate_type2"), - "Oil Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 143, "potduplicate_type2"), - "Cloth Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 144, "potduplicate_type2"), - "Wood Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 145, "potduplicate_type2"), - "Crystal Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 146, "potduplicate_type2"), - "Lightning Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 147, "potduplicate_type2"), - "Sand Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 148, "potduplicate_type2"), - "Metal Pot Complete DUPE": ItemData(SHIVERS_ITEM_ID_OFFSET + 149, "potduplicate_type2"), - - #Filler - "Empty": ItemData(SHIVERS_ITEM_ID_OFFSET + 90, "filler"), - "Easier Lyre": ItemData(SHIVERS_ITEM_ID_OFFSET + 91, "filler", ItemClassification.filler), - "Water Always Available in Lobby": ItemData(SHIVERS_ITEM_ID_OFFSET + 92, "filler2", ItemClassification.filler), - "Wax Always Available in Library": ItemData(SHIVERS_ITEM_ID_OFFSET + 93, "filler2", ItemClassification.filler), - "Wax Always Available in Anansi Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 94, "filler2", ItemClassification.filler), - "Wax Always Available in Shaman Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 95, "filler2", ItemClassification.filler), - "Ash Always Available in Office": ItemData(SHIVERS_ITEM_ID_OFFSET + 96, "filler2", ItemClassification.filler), - "Ash Always Available in Burial Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 97, "filler2", ItemClassification.filler), - "Oil Always Available in Prehistoric Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 98, "filler2", ItemClassification.filler), - "Cloth Always Available in Egypt": ItemData(SHIVERS_ITEM_ID_OFFSET + 99, "filler2", ItemClassification.filler), - "Cloth Always Available in Burial Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 100, "filler2", ItemClassification.filler), - "Wood Always Available in Workshop": ItemData(SHIVERS_ITEM_ID_OFFSET + 101, "filler2", ItemClassification.filler), - "Wood Always Available in Blue Maze": ItemData(SHIVERS_ITEM_ID_OFFSET + 102, "filler2", ItemClassification.filler), - "Wood Always Available in Pegasus Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 103, "filler2", ItemClassification.filler), - "Wood Always Available in Gods Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 104, "filler2", ItemClassification.filler), - "Crystal Always Available in Lobby": ItemData(SHIVERS_ITEM_ID_OFFSET + 105, "filler2", ItemClassification.filler), - "Crystal Always Available in Ocean": ItemData(SHIVERS_ITEM_ID_OFFSET + 106, "filler2", ItemClassification.filler), - "Sand Always Available in Greenhouse Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 107, "filler2", ItemClassification.filler), - "Sand Always Available in Ocean": ItemData(SHIVERS_ITEM_ID_OFFSET + 108, "filler2", ItemClassification.filler), - "Metal Always Available in Projector Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 109, "filler2", ItemClassification.filler), - "Metal Always Available in Bedroom": ItemData(SHIVERS_ITEM_ID_OFFSET + 110, "filler2", ItemClassification.filler), - "Metal Always Available in Prehistoric": ItemData(SHIVERS_ITEM_ID_OFFSET + 111, "filler2", ItemClassification.filler), - "Heal": ItemData(SHIVERS_ITEM_ID_OFFSET + 112, "filler3", ItemClassification.filler) + # Pot Pieces + "Water Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 0, ItemType.POT), + "Wax Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 1, ItemType.POT), + "Ash Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 2, ItemType.POT), + "Oil Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 3, ItemType.POT), + "Cloth Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 4, ItemType.POT), + "Wood Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 5, ItemType.POT), + "Crystal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 6, ItemType.POT), + "Lightning Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 7, ItemType.POT), + "Sand Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 8, ItemType.POT), + "Metal Pot Bottom": ItemData(SHIVERS_ITEM_ID_OFFSET + 9, ItemType.POT), + "Water Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 10, ItemType.POT), + "Wax Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 11, ItemType.POT), + "Ash Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 12, ItemType.POT), + "Oil Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 13, ItemType.POT), + "Cloth Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 14, ItemType.POT), + "Wood Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 15, ItemType.POT), + "Crystal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 16, ItemType.POT), + "Lightning Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 17, ItemType.POT), + "Sand Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 18, ItemType.POT), + "Metal Pot Top": ItemData(SHIVERS_ITEM_ID_OFFSET + 19, ItemType.POT), + "Water Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 20, ItemType.POT_COMPLETE), + "Wax Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 21, ItemType.POT_COMPLETE), + "Ash Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 22, ItemType.POT_COMPLETE), + "Oil Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 23, ItemType.POT_COMPLETE), + "Cloth Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 24, ItemType.POT_COMPLETE), + "Wood Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 25, ItemType.POT_COMPLETE), + "Crystal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 26, ItemType.POT_COMPLETE), + "Lightning Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 27, ItemType.POT_COMPLETE), + "Sand Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 28, ItemType.POT_COMPLETE), + "Metal Pot Complete": ItemData(SHIVERS_ITEM_ID_OFFSET + 29, ItemType.POT_COMPLETE), + + # Keys + "Key for Office Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 30, ItemType.KEY), + "Key for Bedroom Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 31, ItemType.KEY), + "Key for Three Floor Elevator": ItemData(SHIVERS_ITEM_ID_OFFSET + 32, ItemType.KEY), + "Key for Workshop": ItemData(SHIVERS_ITEM_ID_OFFSET + 33, ItemType.KEY), + "Key for Office": ItemData(SHIVERS_ITEM_ID_OFFSET + 34, ItemType.KEY), + "Key for Prehistoric Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 35, ItemType.KEY), + "Key for Greenhouse": ItemData(SHIVERS_ITEM_ID_OFFSET + 36, ItemType.KEY), + "Key for Ocean Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 37, ItemType.KEY), + "Key for Projector Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 38, ItemType.KEY), + "Key for Generator Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 39, ItemType.KEY), + "Key for Egypt Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 40, ItemType.KEY), + "Key for Library": ItemData(SHIVERS_ITEM_ID_OFFSET + 41, ItemType.KEY), + "Key for Shaman Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 42, ItemType.KEY), + "Key for UFO Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 43, ItemType.KEY), + "Key for Torture Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 44, ItemType.KEY), + "Key for Puzzle Room": ItemData(SHIVERS_ITEM_ID_OFFSET + 45, ItemType.KEY), + "Key for Bedroom": ItemData(SHIVERS_ITEM_ID_OFFSET + 46, ItemType.KEY), + "Key for Underground Lake": ItemData(SHIVERS_ITEM_ID_OFFSET + 47, ItemType.KEY), + "Key for Janitor Closet": ItemData(SHIVERS_ITEM_ID_OFFSET + 48, ItemType.KEY), + "Key for Front Door": ItemData(SHIVERS_ITEM_ID_OFFSET + 49, ItemType.KEY_OPTIONAL), + + # Abilities + "Crawling": ItemData(SHIVERS_ITEM_ID_OFFSET + 50, ItemType.ABILITY), + + # Duplicate pot pieces for fill_Restrictive + "Water Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wax Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Ash Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Oil Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Cloth Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wood Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Crystal Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Lightning Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Sand Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Metal Pot Bottom DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Water Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wax Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Ash Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Oil Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Cloth Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Wood Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Crystal Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Lightning Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Sand Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Metal Pot Top DUPE": ItemData(None, ItemType.POT_DUPLICATE), + "Water Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Wax Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Ash Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Oil Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Cloth Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Wood Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Crystal Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Lightning Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Sand Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + "Metal Pot Complete DUPE": ItemData(None, ItemType.POT_COMPELTE_DUPLICATE), + + # Filler + "Empty": ItemData(None, ItemType.FILLER, ItemClassification.filler), + "Easier Lyre": ItemData(SHIVERS_ITEM_ID_OFFSET + 91, ItemType.FILLER, ItemClassification.useful), + "Water Always Available in Lobby": ItemData( + SHIVERS_ITEM_ID_OFFSET + 92, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wax Always Available in Library": ItemData( + SHIVERS_ITEM_ID_OFFSET + 93, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wax Always Available in Anansi Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 94, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wax Always Available in Shaman Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 95, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Ash Always Available in Office": ItemData( + SHIVERS_ITEM_ID_OFFSET + 96, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Ash Always Available in Burial Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 97, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Oil Always Available in Prehistoric Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 98, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Cloth Always Available in Egypt": ItemData( + SHIVERS_ITEM_ID_OFFSET + 99, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Cloth Always Available in Burial Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 100, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Workshop": ItemData( + SHIVERS_ITEM_ID_OFFSET + 101, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Blue Maze": ItemData( + SHIVERS_ITEM_ID_OFFSET + 102, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Pegasus Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 103, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Wood Always Available in Gods Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 104, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Crystal Always Available in Lobby": ItemData( + SHIVERS_ITEM_ID_OFFSET + 105, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Crystal Always Available in Ocean": ItemData( + SHIVERS_ITEM_ID_OFFSET + 106, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Sand Always Available in Greenhouse": ItemData( + SHIVERS_ITEM_ID_OFFSET + 107, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Sand Always Available in Ocean": ItemData( + SHIVERS_ITEM_ID_OFFSET + 108, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Metal Always Available in Projector Room": ItemData( + SHIVERS_ITEM_ID_OFFSET + 109, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Metal Always Available in Bedroom": ItemData( + SHIVERS_ITEM_ID_OFFSET + 110, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Metal Always Available in Prehistoric": ItemData( + SHIVERS_ITEM_ID_OFFSET + 111, ItemType.IXUPI_AVAILABILITY, ItemClassification.filler + ), + "Heal": ItemData(SHIVERS_ITEM_ID_OFFSET + 112, ItemType.FILLER, ItemClassification.filler), + # Goal items + **goal_items } diff --git a/worlds/shivers/Options.py b/worlds/shivers/Options.py index 72791bef3e7b..2e68c4beecc0 100644 --- a/worlds/shivers/Options.py +++ b/worlds/shivers/Options.py @@ -1,6 +1,11 @@ -from Options import Choice, DefaultOnToggle, Toggle, PerGameCommonOptions, Range from dataclasses import dataclass +from Options import ( + Choice, DefaultOnToggle, ItemDict, ItemSet, LocationSet, OptionGroup, PerGameCommonOptions, Range, Toggle, +) +from . import ItemType, item_table +from .Constants import location_info + class IxupiCapturesNeeded(Range): """ @@ -11,12 +16,13 @@ class IxupiCapturesNeeded(Range): range_end = 10 default = 10 + class LobbyAccess(Choice): """ Chooses how keys needed to reach the lobby are placed. - Normal: Keys are placed anywhere - Early: Keys are placed early - - Local: Keys are placed locally + - Local: Keys are placed locally and early """ display_name = "Lobby Access" option_normal = 0 @@ -24,16 +30,19 @@ class LobbyAccess(Choice): option_local = 2 default = 1 + class PuzzleHintsRequired(DefaultOnToggle): """ If turned on puzzle hints/solutions will be available before the corresponding puzzle is required. - For example: The Red Door puzzle will be logically required only after access to the Beth's Address Book which gives you the solution. + For example: The Red Door puzzle will be logically required only after obtaining access to Beth's Address Book + which gives you the solution. Turning this off allows for greater randomization. """ display_name = "Puzzle Hints Required" + class InformationPlaques(Toggle): """ Adds Information Plaques as checks. @@ -41,12 +50,14 @@ class InformationPlaques(Toggle): """ display_name = "Include Information Plaques" + class FrontDoorUsable(Toggle): """ Adds a key to unlock the front door of the museum. """ display_name = "Front Door Usable" + class ElevatorsStaySolved(DefaultOnToggle): """ Adds elevators as checks and will remain open upon solving them. @@ -54,12 +65,15 @@ class ElevatorsStaySolved(DefaultOnToggle): """ display_name = "Elevators Stay Solved" + class EarlyBeth(DefaultOnToggle): """ - Beth's body is open at the start of the game. This allows any pot piece to be placed in the slide and early checks on the second half of the final riddle. + Beth's body is open at the start of the game. + This allows any pot piece to be placed in the slide and early checks on the second half of the final riddle. """ display_name = "Early Beth" + class EarlyLightning(Toggle): """ Allows lightning to be captured at any point in the game. You will still need to capture all ten Ixupi for victory. @@ -67,6 +81,7 @@ class EarlyLightning(Toggle): """ display_name = "Early Lightning" + class LocationPotPieces(Choice): """ Chooses where pot pieces will be located within the multiworld. @@ -78,6 +93,8 @@ class LocationPotPieces(Choice): option_own_world = 0 option_different_world = 1 option_any_world = 2 + default = 2 + class FullPots(Choice): """ @@ -107,6 +124,61 @@ class PuzzleCollectBehavior(Choice): default = 1 +# Need to override the default options to remove the goal items and goal locations so that they do not show on web. +valid_item_keys = [name for name, data in item_table.items() if data.type != ItemType.GOAL and data.code is not None] +valid_location_keys = [name for name in location_info["all_locations"] if name != "Mystery Solved"] + + +class LocalItems(ItemSet): + """Forces these items to be in their native world.""" + display_name = "Local Items" + rich_text_doc = True + valid_keys = valid_item_keys + + +class NonLocalItems(ItemSet): + """Forces these items to be outside their native world.""" + display_name = "Non-local Items" + rich_text_doc = True + valid_keys = valid_item_keys + + +class StartInventory(ItemDict): + """Start with these items.""" + verify_item_name = True + display_name = "Start Inventory" + rich_text_doc = True + valid_keys = valid_item_keys + + +class StartHints(ItemSet): + """Start with these item's locations prefilled into the ``!hint`` command.""" + display_name = "Start Hints" + rich_text_doc = True + valid_keys = valid_item_keys + + +class StartLocationHints(LocationSet): + """Start with these locations and their item prefilled into the ``!hint`` command.""" + display_name = "Start Location Hints" + rich_text_doc = True + valid_keys = valid_location_keys + + +class ExcludeLocations(LocationSet): + """Prevent these locations from having an important item.""" + display_name = "Excluded Locations" + rich_text_doc = True + valid_keys = valid_location_keys + + +class PriorityLocations(LocationSet): + """Prevent these locations from having an unimportant item.""" + display_name = "Priority Locations" + rich_text_doc = True + valid_keys = valid_location_keys + + @dataclass class ShiversOptions(PerGameCommonOptions): ixupi_captures_needed: IxupiCapturesNeeded @@ -120,3 +192,23 @@ class ShiversOptions(PerGameCommonOptions): location_pot_pieces: LocationPotPieces full_pots: FullPots puzzle_collect_behavior: PuzzleCollectBehavior + local_items: LocalItems + non_local_items: NonLocalItems + start_inventory: StartInventory + start_hints: StartHints + start_location_hints: StartLocationHints + exclude_locations: ExcludeLocations + priority_locations: PriorityLocations + + +shivers_option_groups = [ + OptionGroup("Item & Location Options", [ + LocalItems, + NonLocalItems, + StartInventory, + StartHints, + StartLocationHints, + ExcludeLocations, + PriorityLocations + ], True), +] diff --git a/worlds/shivers/Rules.py b/worlds/shivers/Rules.py index 5288fa2c9c3f..d6ea0fca5926 100644 --- a/worlds/shivers/Rules.py +++ b/worlds/shivers/Rules.py @@ -1,66 +1,69 @@ -from typing import Dict, TYPE_CHECKING from collections.abc import Callable +from typing import Dict, TYPE_CHECKING + from BaseClasses import CollectionState from worlds.generic.Rules import forbid_item +from . import Constants if TYPE_CHECKING: from . import ShiversWorld def water_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Water Pot Bottom", "Water Pot Top", "Water Pot Bottom DUPE", "Water Pot Top DUPE"}, player) or \ - state.has_all({"Water Pot Complete", "Water Pot Complete DUPE"}, player) + return state.has_all({"Water Pot Bottom", "Water Pot Top", "Water Pot Bottom DUPE", "Water Pot Top DUPE"}, player) \ + or state.has_all({"Water Pot Complete", "Water Pot Complete DUPE"}, player) def wax_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Wax Pot Bottom", "Wax Pot Top", "Wax Pot Bottom DUPE", "Wax Pot Top DUPE"}, player) or \ - state.has_all({"Wax Pot Complete", "Wax Pot Complete DUPE"}, player) + return state.has_all({"Wax Pot Bottom", "Wax Pot Top", "Wax Pot Bottom DUPE", "Wax Pot Top DUPE"}, player) \ + or state.has_all({"Wax Pot Complete", "Wax Pot Complete DUPE"}, player) def ash_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Ash Pot Bottom", "Ash Pot Top", "Ash Pot Bottom DUPE", "Ash Pot Top DUPE"}, player) or \ - state.has_all({"Ash Pot Complete", "Ash Pot Complete DUPE"}, player) + return state.has_all({"Ash Pot Bottom", "Ash Pot Top", "Ash Pot Bottom DUPE", "Ash Pot Top DUPE"}, player) \ + or state.has_all({"Ash Pot Complete", "Ash Pot Complete DUPE"}, player) def oil_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Oil Pot Bottom", "Oil Pot Top", "Oil Pot Bottom DUPE", "Oil Pot Top DUPE"}, player) or \ - state.has_all({"Oil Pot Complete", "Oil Pot Complete DUPE"}, player) + return state.has_all({"Oil Pot Bottom", "Oil Pot Top", "Oil Pot Bottom DUPE", "Oil Pot Top DUPE"}, player) \ + or state.has_all({"Oil Pot Complete", "Oil Pot Complete DUPE"}, player) def cloth_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Cloth Pot Bottom", "Cloth Pot Top", "Cloth Pot Bottom DUPE", "Cloth Pot Top DUPE"}, player) or \ - state.has_all({"Cloth Pot Complete", "Cloth Pot Complete DUPE"}, player) + return state.has_all({"Cloth Pot Bottom", "Cloth Pot Top", "Cloth Pot Bottom DUPE", "Cloth Pot Top DUPE"}, player) \ + or state.has_all({"Cloth Pot Complete", "Cloth Pot Complete DUPE"}, player) def wood_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Wood Pot Bottom", "Wood Pot Top", "Wood Pot Bottom DUPE", "Wood Pot Top DUPE"}, player) or \ - state.has_all({"Wood Pot Complete", "Wood Pot Complete DUPE"}, player) + return state.has_all({"Wood Pot Bottom", "Wood Pot Top", "Wood Pot Bottom DUPE", "Wood Pot Top DUPE"}, player) \ + or state.has_all({"Wood Pot Complete", "Wood Pot Complete DUPE"}, player) def crystal_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Crystal Pot Bottom", "Crystal Pot Top", "Crystal Pot Bottom DUPE", "Crystal Pot Top DUPE"}, player) or \ - state.has_all({"Crystal Pot Complete", "Crystal Pot Complete DUPE"}, player) + return state.has_all( + {"Crystal Pot Bottom", "Crystal Pot Top", "Crystal Pot Bottom DUPE", "Crystal Pot Top DUPE"}, player) \ + or state.has_all({"Crystal Pot Complete", "Crystal Pot Complete DUPE"}, player) def sand_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Sand Pot Bottom", "Sand Pot Top", "Sand Pot Bottom DUPE", "Sand Pot Top DUPE"}, player) or \ - state.has_all({"Sand Pot Complete", "Sand Pot Complete DUPE"}, player) + return state.has_all({"Sand Pot Bottom", "Sand Pot Top", "Sand Pot Bottom DUPE", "Sand Pot Top DUPE"}, player) \ + or state.has_all({"Sand Pot Complete", "Sand Pot Complete DUPE"}, player) def metal_capturable(state: CollectionState, player: int) -> bool: - return state.has_all({"Metal Pot Bottom", "Metal Pot Top", "Metal Pot Bottom DUPE", "Metal Pot Top DUPE"}, player) or \ - state.has_all({"Metal Pot Complete", "Metal Pot Complete DUPE"}, player) + return state.has_all({"Metal Pot Bottom", "Metal Pot Top", "Metal Pot Bottom DUPE", "Metal Pot Top DUPE"}, player) \ + or state.has_all({"Metal Pot Complete", "Metal Pot Complete DUPE"}, player) -def lightning_capturable(state: CollectionState, player: int) -> bool: - return (first_nine_ixupi_capturable(state, player) or state.multiworld.worlds[player].options.early_lightning.value) \ - and (state.has_all({"Lightning Pot Bottom", "Lightning Pot Top", "Lightning Pot Bottom DUPE", "Lightning Pot Top DUPE"}, player) or \ - state.has_all({"Lightning Pot Complete", "Lightning Pot Complete DUPE"}, player)) +def lightning_capturable(state: CollectionState, world: "ShiversWorld", player: int) -> bool: + return (first_nine_ixupi_capturable(state, player) or world.options.early_lightning) \ + and (state.has_all( + {"Lightning Pot Bottom", "Lightning Pot Top", "Lightning Pot Bottom DUPE", "Lightning Pot Top DUPE"}, + player) or state.has_all({"Lightning Pot Complete", "Lightning Pot Complete DUPE"}, player)) -def beths_body_available(state: CollectionState, player: int) -> bool: - return (first_nine_ixupi_capturable(state, player) or state.multiworld.worlds[player].options.early_beth.value) \ - and state.can_reach("Generator", "Region", player) +def beths_body_available(state: CollectionState, world: "ShiversWorld", player: int) -> bool: + return first_nine_ixupi_capturable(state, player) or world.options.early_beth def first_nine_ixupi_capturable(state: CollectionState, player: int) -> bool: @@ -71,13 +74,22 @@ def first_nine_ixupi_capturable(state: CollectionState, player: int) -> bool: and metal_capturable(state, player) -def all_skull_dials_available(state: CollectionState, player: int) -> bool: - return state.can_reach("Prehistoric", "Region", player) and state.can_reach("Tar River", "Region", player) \ - and state.can_reach("Egypt", "Region", player) and state.can_reach("Burial", "Region", player) \ - and state.can_reach("Gods Room", "Region", player) and state.can_reach("Werewolf", "Region", player) +def all_skull_dials_set(state: CollectionState, player: int) -> bool: + return state.has_all([ + "Set Skull Dial: Prehistoric", + "Set Skull Dial: Tar River", + "Set Skull Dial: Egypt", + "Set Skull Dial: Burial", + "Set Skull Dial: Gods Room", + "Set Skull Dial: Werewolf" + ], player) + + +def completion_condition(state: CollectionState, player: int) -> bool: + return state.has(f"Mt. Pleasant Tribune: {Constants.years_since_sep_30_1980} year Old Mystery Solved!", player) -def get_rules_lookup(player: int): +def get_rules_lookup(world: "ShiversWorld", player: int): rules_lookup: Dict[str, Dict[str, Callable[[CollectionState], bool]]] = { "entrances": { "To Office Elevator From Underground Blue Tunnels": lambda state: state.has("Key for Office Elevator", player), @@ -90,48 +102,58 @@ def get_rules_lookup(player: int): "To Workshop": lambda state: state.has("Key for Workshop", player), "To Lobby From Office": lambda state: state.has("Key for Office", player), "To Office From Lobby": lambda state: state.has("Key for Office", player), - "To Library From Lobby": lambda state: state.has("Key for Library Room", player), - "To Lobby From Library": lambda state: state.has("Key for Library Room", player), + "To Library From Lobby": lambda state: state.has("Key for Library", player), + "To Lobby From Library": lambda state: state.has("Key for Library", player), "To Prehistoric From Lobby": lambda state: state.has("Key for Prehistoric Room", player), "To Lobby From Prehistoric": lambda state: state.has("Key for Prehistoric Room", player), - "To Greenhouse": lambda state: state.has("Key for Greenhouse Room", player), + "To Greenhouse": lambda state: state.has("Key for Greenhouse", player), "To Ocean From Prehistoric": lambda state: state.has("Key for Ocean Room", player), "To Prehistoric From Ocean": lambda state: state.has("Key for Ocean Room", player), "To Projector Room": lambda state: state.has("Key for Projector Room", player), - "To Generator": lambda state: state.has("Key for Generator Room", player), + "To Generator From Maintenance Tunnels": lambda state: state.has("Key for Generator Room", player), "To Lobby From Egypt": lambda state: state.has("Key for Egypt Room", player), "To Egypt From Lobby": lambda state: state.has("Key for Egypt Room", player), "To Janitor Closet": lambda state: state.has("Key for Janitor Closet", player), "To Shaman From Burial": lambda state: state.has("Key for Shaman Room", player), "To Burial From Shaman": lambda state: state.has("Key for Shaman Room", player), + "To Norse Stone From Gods Room": lambda state: state.has("Aligned Planets", player), "To Inventions From UFO": lambda state: state.has("Key for UFO Room", player), "To UFO From Inventions": lambda state: state.has("Key for UFO Room", player), + "To Orrery From UFO": lambda state: state.has("Viewed Fortune", player), "To Torture From Inventions": lambda state: state.has("Key for Torture Room", player), "To Inventions From Torture": lambda state: state.has("Key for Torture Room", player), "To Torture": lambda state: state.has("Key for Puzzle Room", player), "To Puzzle Room Mastermind From Torture": lambda state: state.has("Key for Puzzle Room", player), "To Bedroom": lambda state: state.has("Key for Bedroom", player), - "To Underground Lake From Underground Tunnels": lambda state: state.has("Key for Underground Lake Room", player), - "To Underground Tunnels From Underground Lake": lambda state: state.has("Key for Underground Lake Room", player), + "To Underground Lake From Underground Tunnels": lambda state: state.has("Key for Underground Lake", player), + "To Underground Tunnels From Underground Lake": lambda state: state.has("Key for Underground Lake", player), "To Outside From Lobby": lambda state: state.has("Key for Front Door", player), "To Lobby From Outside": lambda state: state.has("Key for Front Door", player), - "To Maintenance Tunnels From Theater Back Hallways": lambda state: state.has("Crawling", player), + "To Maintenance Tunnels From Theater Back Hallway": lambda state: state.has("Crawling", player), "To Blue Maze From Egypt": lambda state: state.has("Crawling", player), "To Egypt From Blue Maze": lambda state: state.has("Crawling", player), - "To Lobby From Tar River": lambda state: (state.has("Crawling", player) and oil_capturable(state, player)), - "To Tar River From Lobby": lambda state: (state.has("Crawling", player) and oil_capturable(state, player) and state.can_reach("Tar River", "Region", player)), - "To Burial From Egypt": lambda state: state.can_reach("Egypt", "Region", player), - "To Gods Room From Anansi": lambda state: state.can_reach("Gods Room", "Region", player), - "To Slide Room": lambda state: all_skull_dials_available(state, player), - "To Lobby From Slide Room": lambda state: beths_body_available(state, player), - "To Water Capture From Janitor Closet": lambda state: cloth_capturable(state, player) + "To Lobby From Tar River": lambda state: state.has("Crawling", player) and oil_capturable(state, player), + "To Tar River From Lobby": lambda state: state.has("Crawling", player) and oil_capturable(state, player) and state.can_reach_region("Tar River", player), + "To Burial From Egypt": lambda state: state.can_reach_region("Egypt", player), + "To Gods Room From Anansi": lambda state: state.can_reach_region("Gods Room", player), + "To Slide Room": lambda state: all_skull_dials_set(state, player), + "To Lobby From Slide Room": lambda state: state.has("Lost Your Head", player), + "To Water Capture From Janitor Closet": lambda state: cloth_capturable(state, player), + "To Victory": lambda state: ( + (water_capturable(state, player) + wax_capturable(state, player) + ash_capturable(state, player) + + oil_capturable(state, player) + cloth_capturable(state, player) + wood_capturable(state, player) + + crystal_capturable(state, player) + sand_capturable(state, player) + metal_capturable(state, player) + + lightning_capturable(state, world, player)) >= world.options.ixupi_captures_needed.value + ) }, "locations_required": { - "Puzzle Solved Anansi Musicbox": lambda state: state.can_reach("Clock Tower", "Region", player), - "Accessible: Storage: Janitor Closet": lambda state: cloth_capturable(state, player), - "Accessible: Storage: Tar River": lambda state: oil_capturable(state, player), - "Accessible: Storage: Theater": lambda state: state.can_reach("Projector Room", "Region", player), - "Accessible: Storage: Slide": lambda state: beths_body_available(state, player) and state.can_reach("Slide Room", "Region", player), + "Puzzle Solved Anansi Music Box": lambda state: state.has("Set Song", player), + "Storage: Anansi Music Box": lambda state: state.has("Set Song", player), + "Storage: Clock Tower": lambda state: state.has("Set Time", player), + "Storage: Janitor Closet": lambda state: cloth_capturable(state, player), + "Storage: Tar River": lambda state: oil_capturable(state, player), + "Storage: Theater": lambda state: state.has("Viewed Theater Movie", player), + "Storage: Slide": lambda state: state.has("Lost Your Head", player) and state.can_reach_region("Slide Room", player), "Ixupi Captured Water": lambda state: water_capturable(state, player), "Ixupi Captured Wax": lambda state: wax_capturable(state, player), "Ixupi Captured Ash": lambda state: ash_capturable(state, player), @@ -141,32 +163,28 @@ def get_rules_lookup(player: int): "Ixupi Captured Crystal": lambda state: crystal_capturable(state, player), "Ixupi Captured Sand": lambda state: sand_capturable(state, player), "Ixupi Captured Metal": lambda state: metal_capturable(state, player), - "Final Riddle: Planets Aligned": lambda state: state.can_reach("Fortune Teller", "Region", player), - "Final Riddle: Norse God Stone Message": lambda state: (state.can_reach("Fortune Teller", "Region", player) and state.can_reach("UFO", "Region", player)), - "Final Riddle: Beth's Body Page 17": lambda state: beths_body_available(state, player), - "Final Riddle: Guillotine Dropped": lambda state: beths_body_available(state, player), - "Puzzle Solved Skull Dial Door": lambda state: all_skull_dials_available(state, player), - }, - "locations_puzzle_hints": { - "Puzzle Solved Clock Tower Door": lambda state: state.can_reach("Three Floor Elevator", "Region", player), - "Puzzle Solved Clock Chains": lambda state: state.can_reach("Bedroom", "Region", player), - "Puzzle Solved Shaman Drums": lambda state: state.can_reach("Clock Tower", "Region", player), - "Puzzle Solved Red Door": lambda state: state.can_reach("Maintenance Tunnels", "Region", player), - "Puzzle Solved UFO Symbols": lambda state: state.can_reach("Library", "Region", player), - "Puzzle Solved Maze Door": lambda state: state.can_reach("Projector Room", "Region", player), - "Puzzle Solved Theater Door": lambda state: state.can_reach("Underground Lake", "Region", player), - "Puzzle Solved Columns of RA": lambda state: state.can_reach("Underground Lake", "Region", player), - "Final Riddle: Guillotine Dropped": lambda state: (beths_body_available(state, player) and state.can_reach("Underground Lake", "Region", player)) - }, + "Puzzle Solved Skull Dial Door": lambda state: all_skull_dials_set(state, player), + }, + "puzzle_hints_required": { + "Puzzle Solved Clock Tower Door": lambda state: state.can_reach_region("Three Floor Elevator", player), + "Puzzle Solved Shaman Drums": lambda state: state.can_reach_region("Clock Tower", player), + "Puzzle Solved Red Door": lambda state: state.can_reach_region("Maintenance Tunnels", player), + "Puzzle Solved UFO Symbols": lambda state: state.can_reach_region("Library", player), + "Storage: UFO": lambda state: state.can_reach_region("Library", player), + "Puzzle Solved Maze Door": lambda state: state.has("Viewed Theater Movie", player), + "Puzzle Solved Theater Door": lambda state: state.has("Viewed Egyptian Hieroglyphics Explained", player), + "Puzzle Solved Columns of RA": lambda state: state.has("Viewed Egyptian Hieroglyphics Explained", player), + "Puzzle Solved Atlantis": lambda state: state.can_reach_region("Office", player), + }, "elevators": { - "Puzzle Solved Office Elevator": lambda state: ((state.can_reach("Underground Lake", "Region", player) or state.can_reach("Office", "Region", player)) - and state.has("Key for Office Elevator", player)), - "Puzzle Solved Bedroom Elevator": lambda state: (state.can_reach("Office", "Region", player) and state.has_all({"Key for Bedroom Elevator","Crawling"}, player)), - "Puzzle Solved Three Floor Elevator": lambda state: ((state.can_reach("Maintenance Tunnels", "Region", player) or state.can_reach("Blue Maze", "Region", player)) - and state.has("Key for Three Floor Elevator", player)) - }, + "Puzzle Solved Office Elevator": lambda state: (state.can_reach_region("Underground Lake", player) or state.can_reach_region("Office", player)) + and state.has("Key for Office Elevator", player), + "Puzzle Solved Bedroom Elevator": lambda state: state.has_all({"Key for Bedroom Elevator", "Crawling"}, player), + "Puzzle Solved Three Floor Elevator": lambda state: (state.can_reach_region("Maintenance Tunnels", player) or state.can_reach_region("Blue Maze", player)) + and state.has("Key for Three Floor Elevator", player) + }, "lightning": { - "Ixupi Captured Lightning": lambda state: lightning_capturable(state, player) + "Ixupi Captured Lightning": lambda state: lightning_capturable(state, world, player) } } return rules_lookup @@ -176,69 +194,128 @@ def set_rules(world: "ShiversWorld") -> None: multiworld = world.multiworld player = world.player - rules_lookup = get_rules_lookup(player) + rules_lookup = get_rules_lookup(world, player) # Set required entrance rules for entrance_name, rule in rules_lookup["entrances"].items(): - multiworld.get_entrance(entrance_name, player).access_rule = rule + world.get_entrance(entrance_name).access_rule = rule + + world.get_region("Clock Tower Staircase").connect( + world.get_region("Clock Chains"), + "To Clock Chains From Clock Tower Staircase", + lambda state: state.can_reach_region("Bedroom", player) if world.options.puzzle_hints_required.value else True + ) + + world.get_region("Generator").connect( + world.get_region("Beth's Body"), + "To Beth's Body From Generator", + lambda state: beths_body_available(state, world, player) and ( + (state.has("Viewed Norse Stone", player) and state.can_reach_region("Theater", player)) + if world.options.puzzle_hints_required.value else True + ) + ) + + world.get_region("Torture").connect( + world.get_region("Guillotine"), + "To Guillotine From Torture", + lambda state: state.has("Viewed Page 17", player) and ( + state.has("Viewed Egyptian Hieroglyphics Explained", player) + if world.options.puzzle_hints_required.value else True + ) + ) # Set required location rules for location_name, rule in rules_lookup["locations_required"].items(): - multiworld.get_location(location_name, player).access_rule = rule + world.get_location(location_name).access_rule = rule + + world.get_location("Jukebox").access_rule = lambda state: ( + state.can_reach_region("Clock Tower", player) and ( + state.can_reach_region("Anansi", player) + if world.options.puzzle_hints_required.value else True + ) + ) # Set option location rules if world.options.puzzle_hints_required.value: - for location_name, rule in rules_lookup["locations_puzzle_hints"].items(): - multiworld.get_location(location_name, player).access_rule = rule + for location_name, rule in rules_lookup["puzzle_hints_required"].items(): + world.get_location(location_name).access_rule = rule + + world.get_entrance("To Theater From Lobby").access_rule = lambda state: state.has( + "Viewed Egyptian Hieroglyphics Explained", player + ) + + world.get_entrance("To Clock Tower Staircase From Theater Back Hallway").access_rule = lambda state: state.can_reach_region("Three Floor Elevator", player) + multiworld.register_indirect_condition( + world.get_region("Three Floor Elevator"), + world.get_entrance("To Clock Tower Staircase From Theater Back Hallway") + ) + + world.get_entrance("To Gods Room From Shaman").access_rule = lambda state: state.can_reach_region( + "Clock Tower", player + ) + multiworld.register_indirect_condition( + world.get_region("Clock Tower"), world.get_entrance("To Gods Room From Shaman") + ) + + world.get_entrance("To Anansi From Gods Room").access_rule = lambda state: state.can_reach_region( + "Maintenance Tunnels", player + ) + multiworld.register_indirect_condition( + world.get_region("Maintenance Tunnels"), world.get_entrance("To Anansi From Gods Room") + ) + + world.get_entrance("To Maze From Maze Staircase").access_rule = lambda \ + state: state.can_reach_region("Projector Room", player) + multiworld.register_indirect_condition( + world.get_region("Projector Room"), world.get_entrance("To Maze From Maze Staircase") + ) + + multiworld.register_indirect_condition( + world.get_region("Bedroom"), world.get_entrance("To Clock Chains From Clock Tower Staircase") + ) + multiworld.register_indirect_condition( + world.get_region("Theater"), world.get_entrance("To Beth's Body From Generator") + ) + if world.options.elevators_stay_solved.value: for location_name, rule in rules_lookup["elevators"].items(): - multiworld.get_location(location_name, player).access_rule = rule + world.get_location(location_name).access_rule = rule if world.options.early_lightning.value: for location_name, rule in rules_lookup["lightning"].items(): - multiworld.get_location(location_name, player).access_rule = rule + world.get_location(location_name).access_rule = rule # Register indirect conditions - multiworld.register_indirect_condition(world.get_region("Burial"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Egypt"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Gods Room"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Prehistoric"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Tar River"), world.get_entrance("To Slide Room")) - multiworld.register_indirect_condition(world.get_region("Werewolf"), world.get_entrance("To Slide Room")) multiworld.register_indirect_condition(world.get_region("Prehistoric"), world.get_entrance("To Tar River From Lobby")) # forbid cloth in janitor closet and oil in tar river - forbid_item(multiworld.get_location("Accessible: Storage: Janitor Closet", player), "Cloth Pot Bottom DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Janitor Closet", player), "Cloth Pot Top DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Janitor Closet", player), "Cloth Pot Complete DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Tar River", player), "Oil Pot Bottom DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Tar River", player), "Oil Pot Top DUPE", player) - forbid_item(multiworld.get_location("Accessible: Storage: Tar River", player), "Oil Pot Complete DUPE", player) + forbid_item(world.get_location("Storage: Janitor Closet"), "Cloth Pot Bottom DUPE", player) + forbid_item(world.get_location("Storage: Janitor Closet"), "Cloth Pot Top DUPE", player) + forbid_item(world.get_location("Storage: Janitor Closet"), "Cloth Pot Complete DUPE", player) + forbid_item(world.get_location("Storage: Tar River"), "Oil Pot Bottom DUPE", player) + forbid_item(world.get_location("Storage: Tar River"), "Oil Pot Top DUPE", player) + forbid_item(world.get_location("Storage: Tar River"), "Oil Pot Complete DUPE", player) # Filler Item Forbids - forbid_item(multiworld.get_location("Puzzle Solved Lyre", player), "Easier Lyre", player) - forbid_item(multiworld.get_location("Ixupi Captured Water", player), "Water Always Available in Lobby", player) - forbid_item(multiworld.get_location("Ixupi Captured Wax", player), "Wax Always Available in Library", player) - forbid_item(multiworld.get_location("Ixupi Captured Wax", player), "Wax Always Available in Anansi Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Wax", player), "Wax Always Available in Shaman Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Ash", player), "Ash Always Available in Office", player) - forbid_item(multiworld.get_location("Ixupi Captured Ash", player), "Ash Always Available in Burial Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Oil", player), "Oil Always Available in Prehistoric Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Cloth", player), "Cloth Always Available in Egypt", player) - forbid_item(multiworld.get_location("Ixupi Captured Cloth", player), "Cloth Always Available in Burial Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Workshop", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Blue Maze", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Pegasus Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Wood", player), "Wood Always Available in Gods Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Crystal", player), "Crystal Always Available in Lobby", player) - forbid_item(multiworld.get_location("Ixupi Captured Crystal", player), "Crystal Always Available in Ocean", player) - forbid_item(multiworld.get_location("Ixupi Captured Sand", player), "Sand Always Available in Plants Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Sand", player), "Sand Always Available in Ocean", player) - forbid_item(multiworld.get_location("Ixupi Captured Metal", player), "Metal Always Available in Projector Room", player) - forbid_item(multiworld.get_location("Ixupi Captured Metal", player), "Metal Always Available in Bedroom", player) - forbid_item(multiworld.get_location("Ixupi Captured Metal", player), "Metal Always Available in Prehistoric", player) + forbid_item(world.get_location("Puzzle Solved Lyre"), "Easier Lyre", player) + forbid_item(world.get_location("Ixupi Captured Water"), "Water Always Available in Lobby", player) + forbid_item(world.get_location("Ixupi Captured Wax"), "Wax Always Available in Library", player) + forbid_item(world.get_location("Ixupi Captured Wax"), "Wax Always Available in Anansi Room", player) + forbid_item(world.get_location("Ixupi Captured Wax"), "Wax Always Available in Shaman Room", player) + forbid_item(world.get_location("Ixupi Captured Ash"), "Ash Always Available in Office", player) + forbid_item(world.get_location("Ixupi Captured Ash"), "Ash Always Available in Burial Room", player) + forbid_item(world.get_location("Ixupi Captured Oil"), "Oil Always Available in Prehistoric Room", player) + forbid_item(world.get_location("Ixupi Captured Cloth"), "Cloth Always Available in Egypt", player) + forbid_item(world.get_location("Ixupi Captured Cloth"), "Cloth Always Available in Burial Room", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Workshop", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Blue Maze", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Pegasus Room", player) + forbid_item(world.get_location("Ixupi Captured Wood"), "Wood Always Available in Gods Room", player) + forbid_item(world.get_location("Ixupi Captured Crystal"), "Crystal Always Available in Lobby", player) + forbid_item(world.get_location("Ixupi Captured Crystal"), "Crystal Always Available in Ocean", player) + forbid_item(world.get_location("Ixupi Captured Sand"), "Sand Always Available in Plants Room", player) + forbid_item(world.get_location("Ixupi Captured Sand"), "Sand Always Available in Ocean", player) + forbid_item(world.get_location("Ixupi Captured Metal"), "Metal Always Available in Projector Room", player) + forbid_item(world.get_location("Ixupi Captured Metal"), "Metal Always Available in Bedroom", player) + forbid_item(world.get_location("Ixupi Captured Metal"), "Metal Always Available in Prehistoric", player) # Set completion condition - multiworld.completion_condition[player] = lambda state: (( - water_capturable(state, player) + wax_capturable(state, player) + ash_capturable(state, player) \ - + oil_capturable(state, player) + cloth_capturable(state, player) + wood_capturable(state, player) \ - + crystal_capturable(state, player) + sand_capturable(state, player) + metal_capturable(state, player) \ - + lightning_capturable(state, player)) >= world.options.ixupi_captures_needed.value) + multiworld.completion_condition[player] = lambda state: completion_condition(state, player) diff --git a/worlds/shivers/__init__.py b/worlds/shivers/__init__.py index 3ca87ae164f2..6a41dce376b3 100644 --- a/worlds/shivers/__init__.py +++ b/worlds/shivers/__init__.py @@ -1,11 +1,12 @@ -from typing import List -from .Items import item_table, ShiversItem -from .Rules import set_rules -from BaseClasses import Item, Tutorial, Region, Location +from typing import Dict, List, Optional + +from BaseClasses import Item, ItemClassification, Location, Region, Tutorial from Fill import fill_restrictive from worlds.AutoWorld import WebWorld, World from . import Constants, Rules -from .Options import ShiversOptions +from .Items import ItemType, SHIVERS_ITEM_ID_OFFSET, ShiversItem, item_table +from .Options import ShiversOptions, shivers_option_groups +from .Rules import set_rules class ShiversWeb(WebWorld): @@ -17,10 +18,13 @@ class ShiversWeb(WebWorld): "setup/en", ["GodlFire", "Mathx2"] )] + option_groups = shivers_option_groups + class ShiversWorld(World): """ - Shivers is a horror themed point and click adventure. Explore the mysteries of Windlenot's Museum of the Strange and Unusual. + Shivers is a horror themed point and click adventure. + Explore the mysteries of Windlenot's Museum of the Strange and Unusual. """ game = "Shivers" @@ -28,13 +32,12 @@ class ShiversWorld(World): web = ShiversWeb() options_dataclass = ShiversOptions options: ShiversOptions - + set_rules = set_rules item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = Constants.location_name_to_id - shivers_item_id_offset = 27000 + storage_placements = [] pot_completed_list: List[int] - def generate_early(self): self.pot_completed_list = [] @@ -42,10 +45,14 @@ def create_item(self, name: str) -> Item: data = item_table[name] return ShiversItem(name, data.classification, data.code, self.player) - def create_event(self, region_name: str, event_name: str) -> None: - region = self.multiworld.get_region(region_name, self.player) - loc = ShiversLocation(self.player, event_name, None, region) - loc.place_locked_item(self.create_event_item(event_name)) + def create_event_location(self, region_name: str, location_name: str, event_name: Optional[str] = None) -> None: + region = self.get_region(region_name) + loc = ShiversLocation(self.player, location_name, None, region) + if event_name is not None: + loc.place_locked_item(ShiversItem(event_name, ItemClassification.progression, None, self.player)) + else: + loc.place_locked_item(ShiversItem(location_name, ItemClassification.progression, None, self.player)) + loc.show_in_spoiler = False region.locations.append(loc) def create_regions(self) -> None: @@ -56,162 +63,193 @@ def create_regions(self) -> None: for exit_name in exits: r.create_exit(exit_name) - # Bind mandatory connections for entr_name, region_name in Constants.region_info["mandatory_connections"]: - e = self.multiworld.get_entrance(entr_name, self.player) - r = self.multiworld.get_region(region_name, self.player) + e = self.get_entrance(entr_name) + r = self.get_region(region_name) e.connect(r) # Locations # Build exclusion list - self.removed_locations = set() + removed_locations = set() if not self.options.include_information_plaques: - self.removed_locations.update(Constants.exclusion_info["plaques"]) + removed_locations.update(Constants.exclusion_info["plaques"]) if not self.options.elevators_stay_solved: - self.removed_locations.update(Constants.exclusion_info["elevators"]) + removed_locations.update(Constants.exclusion_info["elevators"]) if not self.options.early_lightning: - self.removed_locations.update(Constants.exclusion_info["lightning"]) + removed_locations.update(Constants.exclusion_info["lightning"]) # Add locations for region_name, locations in Constants.location_info["locations_by_region"].items(): - region = self.multiworld.get_region(region_name, self.player) + region = self.get_region(region_name) for loc_name in locations: - if loc_name not in self.removed_locations: + if loc_name not in removed_locations: loc = ShiversLocation(self.player, loc_name, self.location_name_to_id.get(loc_name, None), region) region.locations.append(loc) + self.create_event_location("Prehistoric", "Set Skull Dial: Prehistoric") + self.create_event_location("Tar River", "Set Skull Dial: Tar River") + self.create_event_location("Egypt", "Set Skull Dial: Egypt") + self.create_event_location("Burial", "Set Skull Dial: Burial") + self.create_event_location("Gods Room", "Set Skull Dial: Gods Room") + self.create_event_location("Werewolf", "Set Skull Dial: Werewolf") + self.create_event_location("Projector Room", "Viewed Theater Movie") + self.create_event_location("Clock Chains", "Clock Chains", "Set Time") + self.create_event_location("Clock Tower", "Jukebox", "Set Song") + self.create_event_location("Fortune Teller", "Viewed Fortune") + self.create_event_location("Orrery", "Orrery", "Aligned Planets") + self.create_event_location("Norse Stone", "Norse Stone", "Viewed Norse Stone") + self.create_event_location("Beth's Body", "Beth's Body", "Viewed Page 17") + self.create_event_location("Windlenot's Body", "Windlenot's Body", "Viewed Egyptian Hieroglyphics Explained") + self.create_event_location("Guillotine", "Guillotine", "Lost Your Head") + def create_items(self) -> None: - #Add items to item pool - itempool = [] + # Add items to item pool + item_pool = [] for name, data in item_table.items(): - if data.type in {"key", "ability", "filler2"}: - itempool.append(self.create_item(name)) + if data.type in [ItemType.KEY, ItemType.ABILITY, ItemType.IXUPI_AVAILABILITY]: + item_pool.append(self.create_item(name)) # Pot pieces/Completed/Mixed: - for i in range(10): - if self.options.full_pots == "pieces": - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + i])) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 10 + i])) - elif self.options.full_pots == "complete": - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 20 + i])) - else: - # Roll for if pieces or a complete pot will be used. - # Pot Pieces + if self.options.full_pots == "pieces": + item_pool += [self.create_item(name) for name, data in item_table.items() if data.type == ItemType.POT] + elif self.options.full_pots == "complete": + item_pool += [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPLETE] + else: + # Roll for if pieces or a complete pot will be used. + # Pot Pieces + pieces = [self.create_item(name) for name, data in item_table.items() if data.type == ItemType.POT] + complete = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPLETE] + for i in range(10): if self.random.randint(0, 1) == 0: self.pot_completed_list.append(0) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + i])) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 10 + i])) + item_pool.append(pieces[i]) + item_pool.append(pieces[i + 10]) # Completed Pot else: self.pot_completed_list.append(1) - itempool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 20 + i])) - - #Add Filler - itempool += [self.create_item("Easier Lyre") for i in range(9)] + item_pool.append(complete[i]) - #Extra filler is random between Heals and Easier Lyre. Heals weighted 95%. - filler_needed = len(self.multiworld.get_unfilled_locations(self.player)) - 24 - len(itempool) - itempool += [self.random.choices([self.create_item("Heal"), self.create_item("Easier Lyre")], weights=[95, 5])[0] for i in range(filler_needed)] + # Add Easier Lyre + item_pool += [self.create_item("Easier Lyre") for _ in range(9)] - #Place library escape items. Choose a location to place the escape item - library_region = self.multiworld.get_region("Library", self.player) - librarylocation = self.random.choice([loc for loc in library_region.locations if not loc.name.startswith("Accessible:")]) + # Place library escape items. Choose a location to place the escape item + library_region = self.get_region("Library") + library_location = self.random.choice( + [loc for loc in library_region.locations if not loc.name.startswith("Storage: ")] + ) - #Roll for which escape items will be placed in the Library + # Roll for which escape items will be placed in the Library library_random = self.random.randint(1, 3) - if library_random == 1: - librarylocation.place_locked_item(self.create_item("Crawling")) - - itempool = [item for item in itempool if item.name != "Crawling"] - - elif library_random == 2: - librarylocation.place_locked_item(self.create_item("Key for Library Room")) - - itempool = [item for item in itempool if item.name != "Key for Library Room"] - elif library_random == 3: - librarylocation.place_locked_item(self.create_item("Key for Three Floor Elevator")) - - librarylocationkeytwo = self.random.choice([loc for loc in library_region.locations if not loc.name.startswith("Accessible:") and loc != librarylocation]) - librarylocationkeytwo.place_locked_item(self.create_item("Key for Egypt Room")) - - itempool = [item for item in itempool if item.name not in ["Key for Three Floor Elevator", "Key for Egypt Room"]] - - #If front door option is on, determine which set of keys will be used for lobby access and add front door key to item pool - lobby_access_keys = 1 + if library_random == 1: + library_location.place_locked_item(self.create_item("Crawling")) + item_pool = [item for item in item_pool if item.name != "Crawling"] + elif library_random == 2: + library_location.place_locked_item(self.create_item("Key for Library")) + item_pool = [item for item in item_pool if item.name != "Key for Library"] + elif library_random == 3: + library_location.place_locked_item(self.create_item("Key for Three Floor Elevator")) + library_location_2 = self.random.choice( + [loc for loc in library_region.locations if + not loc.name.startswith("Storage: ") and loc != library_location] + ) + library_location_2.place_locked_item(self.create_item("Key for Egypt Room")) + item_pool = [item for item in item_pool if + item.name not in ["Key for Three Floor Elevator", "Key for Egypt Room"]] + + # If front door option is on, determine which set of keys will + # be used for lobby access and add front door key to item pool + lobby_access_keys = 0 if self.options.front_door_usable: - lobby_access_keys = self.random.randint(1, 2) - itempool += [self.create_item("Key for Front Door")] + lobby_access_keys = self.random.randint(0, 1) + item_pool.append(self.create_item("Key for Front Door")) else: - itempool += [self.create_item("Heal")] + item_pool.append(self.create_item("Heal")) - self.multiworld.itempool += itempool + def set_lobby_access_keys(items: Dict[str, int]): + if lobby_access_keys == 0: + items["Key for Underground Lake"] = 1 + items["Key for Office Elevator"] = 1 + items["Key for Office"] = 1 + else: + items["Key for Front Door"] = 1 - #Lobby acess: + # Lobby access: if self.options.lobby_access == "early": - if lobby_access_keys == 1: - self.multiworld.early_items[self.player]["Key for Underground Lake Room"] = 1 - self.multiworld.early_items[self.player]["Key for Office Elevator"] = 1 - self.multiworld.early_items[self.player]["Key for Office"] = 1 - elif lobby_access_keys == 2: - self.multiworld.early_items[self.player]["Key for Front Door"] = 1 - if self.options.lobby_access == "local": - if lobby_access_keys == 1: - self.multiworld.local_early_items[self.player]["Key for Underground Lake Room"] = 1 - self.multiworld.local_early_items[self.player]["Key for Office Elevator"] = 1 - self.multiworld.local_early_items[self.player]["Key for Office"] = 1 - elif lobby_access_keys == 2: - self.multiworld.local_early_items[self.player]["Key for Front Door"] = 1 - - #Pot piece shuffle location: + set_lobby_access_keys(self.multiworld.early_items[self.player]) + elif self.options.lobby_access == "local": + set_lobby_access_keys(self.multiworld.local_early_items[self.player]) + + goal_item_code = SHIVERS_ITEM_ID_OFFSET + 100 + Constants.years_since_sep_30_1980 + for name, data in item_table.items(): + if data.type == ItemType.GOAL and data.code == goal_item_code: + goal = self.create_item(name) + self.get_location("Mystery Solved").place_locked_item(goal) + + # Extra filler is random between Heals and Easier Lyre. Heals weighted 95%. + filler_needed = len(self.multiworld.get_unfilled_locations(self.player)) - len(item_pool) - 23 + item_pool += map(self.create_item, self.random.choices( + ["Heal", "Easier Lyre"], weights=[95, 5], k=filler_needed + )) + + # Pot piece shuffle location: if self.options.location_pot_pieces == "own_world": - self.options.local_items.value |= {name for name, data in item_table.items() if data.type == "pot" or data.type == "pot_type2"} - if self.options.location_pot_pieces == "different_world": - self.options.non_local_items.value |= {name for name, data in item_table.items() if data.type == "pot" or data.type == "pot_type2"} + self.options.local_items.value |= {name for name, data in item_table.items() if + data.type in [ItemType.POT, ItemType.POT_COMPLETE]} + elif self.options.location_pot_pieces == "different_world": + self.options.non_local_items.value |= {name for name, data in item_table.items() if + data.type in [ItemType.POT, ItemType.POT_COMPLETE]} + + self.multiworld.itempool += item_pool def pre_fill(self) -> None: # Prefills event storage locations with duplicate pots - storagelocs = [] - storageitems = [] - self.storage_placements = [] + storage_locs = [] + storage_items = [] for locations in Constants.location_info["locations_by_region"].values(): for loc_name in locations: - if loc_name.startswith("Accessible: "): - storagelocs.append(self.multiworld.get_location(loc_name, self.player)) + if loc_name.startswith("Storage: "): + storage_locs.append(self.get_location(loc_name)) - #Pot pieces/Completed/Mixed: + # Pot pieces/Completed/Mixed: if self.options.full_pots == "pieces": - storageitems += [self.create_item(name) for name, data in item_table.items() if data.type == 'potduplicate'] + storage_items += [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_DUPLICATE] elif self.options.full_pots == "complete": - storageitems += [self.create_item(name) for name, data in item_table.items() if data.type == 'potduplicate_type2'] - storageitems += [self.create_item("Empty") for i in range(10)] + storage_items += [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPELTE_DUPLICATE] + storage_items += [self.create_item("Empty") for _ in range(10)] else: + pieces = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_DUPLICATE] + complete = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPELTE_DUPLICATE] for i in range(10): - #Pieces + # Pieces if self.pot_completed_list[i] == 0: - storageitems += [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 70 + i])] - storageitems += [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 80 + i])] - #Complete + storage_items.append(pieces[i]) + storage_items.append(pieces[i + 10]) + # Complete else: - storageitems += [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 140 + i])] - storageitems += [self.create_item("Empty")] + storage_items.append(complete[i]) + storage_items.append(self.create_item("Empty")) - storageitems += [self.create_item("Empty") for i in range(3)] + storage_items += [self.create_item("Empty") for _ in range(3)] state = self.multiworld.get_all_state(True) - self.random.shuffle(storagelocs) - self.random.shuffle(storageitems) - - fill_restrictive(self.multiworld, state, storagelocs.copy(), storageitems, True, True) + self.random.shuffle(storage_locs) + self.random.shuffle(storage_items) - self.storage_placements = {location.name: location.item.name for location in storagelocs} + fill_restrictive(self.multiworld, state, storage_locs.copy(), storage_items, True, True) - set_rules = set_rules + self.storage_placements = {location.name.replace("Storage: ", ""): location.item.name.replace(" DUPE", "") for + location in storage_locs} def fill_slot_data(self) -> dict: - return { "StoragePlacements": self.storage_placements, "ExcludedLocations": list(self.options.exclude_locations.value), diff --git a/worlds/shivers/data/excluded_locations.json b/worlds/shivers/data/excluded_locations.json index 29655d4a5024..1f012964cc61 100644 --- a/worlds/shivers/data/excluded_locations.json +++ b/worlds/shivers/data/excluded_locations.json @@ -11,7 +11,7 @@ "Information Plaque: (Ocean) Poseidon", "Information Plaque: (Ocean) Colossus of Rhodes", "Information Plaque: (Ocean) Poseidon's Temple", - "Information Plaque: (Underground Maze) Subterranean World", + "Information Plaque: (Underground Maze Staircase) Subterranean World", "Information Plaque: (Underground Maze) Dero", "Information Plaque: (Egypt) Tomb of the Ixupi", "Information Plaque: (Egypt) The Sphinx", diff --git a/worlds/shivers/data/locations.json b/worlds/shivers/data/locations.json index 64fe3647348d..41fe517061a8 100644 --- a/worlds/shivers/data/locations.json +++ b/worlds/shivers/data/locations.json @@ -19,7 +19,7 @@ "Puzzle Solved Fortune Teller Door", "Puzzle Solved Alchemy", "Puzzle Solved UFO Symbols", - "Puzzle Solved Anansi Musicbox", + "Puzzle Solved Anansi Music Box", "Puzzle Solved Gallows", "Puzzle Solved Mastermind", "Puzzle Solved Marble Flipper", @@ -54,7 +54,7 @@ "Final Riddle: Norse God Stone Message", "Final Riddle: Beth's Body Page 17", "Final Riddle: Guillotine Dropped", - "Puzzle Hint Found: Combo Lock in Mailbox", + "Puzzle Hint Found: Mailbox", "Puzzle Hint Found: Orange Symbol", "Puzzle Hint Found: Silver Symbol", "Puzzle Hint Found: Green Symbol", @@ -113,15 +113,19 @@ "Puzzle Solved Office Elevator", "Puzzle Solved Bedroom Elevator", "Puzzle Solved Three Floor Elevator", - "Ixupi Captured Lightning" + "Ixupi Captured Lightning", + "Puzzle Solved Combination Lock", + "Puzzle Hint Found: Beth's Note", + "Mystery Solved" ], "locations_by_region": { "Outside": [ + "Puzzle Solved Combination Lock", "Puzzle Solved Gears", "Puzzle Solved Stone Henge", "Puzzle Solved Office Elevator", "Puzzle Solved Three Floor Elevator", - "Puzzle Hint Found: Combo Lock in Mailbox", + "Puzzle Hint Found: Mailbox", "Puzzle Hint Found: Orange Symbol", "Puzzle Hint Found: Silver Symbol", "Puzzle Hint Found: Green Symbol", @@ -130,32 +134,42 @@ "Puzzle Hint Found: Tan Symbol" ], "Underground Lake": [ - "Flashback Memory Obtained Windlenot's Ghost", + "Flashback Memory Obtained Windlenot's Ghost" + ], + "Windlenot's Body": [ "Flashback Memory Obtained Egyptian Hieroglyphics Explained" ], "Office": [ "Flashback Memory Obtained Scrapbook", - "Accessible: Storage: Desk Drawer", + "Storage: Desk Drawer", "Puzzle Hint Found: Atlantis Map", "Puzzle Hint Found: Tape Recorder Heard", "Puzzle Solved Bedroom Elevator" ], "Workshop": [ "Puzzle Solved Workshop Drawers", - "Accessible: Storage: Workshop Drawers", + "Storage: Workshop Drawers", "Puzzle Hint Found: Basilisk Bone Fragments" ], "Bedroom": [ "Flashback Memory Obtained Professor Windlenot's Diary" ], + "Lobby": [ + "Puzzle Solved Theater Door", + "Flashback Memory Obtained Museum Brochure", + "Information Plaque: (Lobby) Jade Skull", + "Information Plaque: (Lobby) Transforming Masks", + "Storage: Slide", + "Storage: Transforming Mask" + ], "Library": [ "Puzzle Solved Library Statue", "Flashback Memory Obtained In Search of the Unexplained", "Flashback Memory Obtained South American Pictographs", "Flashback Memory Obtained Mythology of the Stars", "Flashback Memory Obtained Black Book", - "Accessible: Storage: Library Cabinet", - "Accessible: Storage: Library Statue" + "Storage: Library Cabinet", + "Storage: Library Statue" ], "Maintenance Tunnels": [ "Flashback Memory Obtained Beth's Address Book" @@ -163,37 +177,46 @@ "Three Floor Elevator": [ "Puzzle Hint Found: Elevator Writing" ], - "Lobby": [ - "Puzzle Solved Theater Door", - "Flashback Memory Obtained Museum Brochure", - "Information Plaque: (Lobby) Jade Skull", - "Information Plaque: (Lobby) Transforming Masks", - "Accessible: Storage: Slide", - "Accessible: Storage: Transforming Mask" - ], "Generator": [ - "Final Riddle: Beth's Body Page 17", "Ixupi Captured Lightning" ], - "Theater Back Hallways": [ + "Beth's Body": [ + "Final Riddle: Beth's Body Page 17" + ], + "Theater": [ + "Storage: Theater", + "Puzzle Hint Found: Beth's Note" + ], + "Theater Back Hallway": [ "Puzzle Solved Clock Tower Door" ], - "Clock Tower Staircase": [ + "Clock Chains": [ "Puzzle Solved Clock Chains" ], "Clock Tower": [ "Flashback Memory Obtained Beth's Ghost", - "Accessible: Storage: Clock Tower", + "Storage: Clock Tower", "Puzzle Hint Found: Shaman Security Camera" ], "Projector Room": [ "Flashback Memory Obtained Theater Movie" ], + "Prehistoric": [ + "Information Plaque: (Prehistoric) Bronze Unicorn", + "Information Plaque: (Prehistoric) Griffin", + "Information Plaque: (Prehistoric) Eagles Nest", + "Information Plaque: (Prehistoric) Large Spider", + "Information Plaque: (Prehistoric) Starfish", + "Storage: Eagles Nest" + ], + "Greenhouse": [ + "Storage: Greenhouse" + ], "Ocean": [ "Puzzle Solved Atlantis", "Puzzle Solved Organ", "Flashback Memory Obtained Museum Blueprints", - "Accessible: Storage: Ocean", + "Storage: Ocean", "Puzzle Hint Found: Sirens Song Heard", "Information Plaque: (Ocean) Quartz Crystal", "Information Plaque: (Ocean) Poseidon", @@ -204,10 +227,14 @@ "Information Plaque: (Underground Maze Staircase) Subterranean World", "Puzzle Solved Maze Door" ], + "Tar River": [ + "Storage: Tar River", + "Information Plaque: (Underground Maze) Dero" + ], "Egypt": [ "Puzzle Solved Columns of RA", "Puzzle Solved Burial Door", - "Accessible: Storage: Egypt", + "Storage: Egypt", "Puzzle Hint Found: Egyptian Sphinx Heard", "Information Plaque: (Egypt) Tomb of the Ixupi", "Information Plaque: (Egypt) The Sphinx", @@ -216,7 +243,7 @@ "Burial": [ "Puzzle Solved Chinese Solitaire", "Flashback Memory Obtained Merrick's Notebook", - "Accessible: Storage: Chinese Solitaire", + "Storage: Chinese Solitaire", "Information Plaque: (Burial) Norse Burial Ship", "Information Plaque: (Burial) Paracas Burial Bundles", "Information Plaque: (Burial) Spectacular Coffins of Ghana", @@ -225,15 +252,14 @@ ], "Shaman": [ "Puzzle Solved Shaman Drums", - "Accessible: Storage: Shaman Hut", + "Storage: Shaman Hut", "Information Plaque: (Shaman) Witch Doctors of the Congo", "Information Plaque: (Shaman) Sarombe doctor of Mozambique" ], "Gods Room": [ "Puzzle Solved Lyre", "Puzzle Solved Red Door", - "Accessible: Storage: Lyre", - "Final Riddle: Norse God Stone Message", + "Storage: Lyre", "Information Plaque: (Gods) Fisherman's Canoe God", "Information Plaque: (Gods) Mayan Gods", "Information Plaque: (Gods) Thor", @@ -242,6 +268,9 @@ "Information Plaque: (Gods) Sumerian Lyre", "Information Plaque: (Gods) Chuen" ], + "Norse Stone": [ + "Final Riddle: Norse God Stone Message" + ], "Blue Maze": [ "Puzzle Solved Fortune Teller Door" ], @@ -251,35 +280,46 @@ ], "Inventions": [ "Puzzle Solved Alchemy", - "Accessible: Storage: Alchemy" + "Storage: Alchemy" ], "UFO": [ "Puzzle Solved UFO Symbols", - "Accessible: Storage: UFO", - "Final Riddle: Planets Aligned", + "Storage: UFO", "Information Plaque: (UFO) Coincidence or Extraterrestrial Visits?", "Information Plaque: (UFO) Planets", "Information Plaque: (UFO) Astronomical Construction", "Information Plaque: (UFO) Aliens" ], + "Orrery": [ + "Final Riddle: Planets Aligned" + ], + "Janitor Closet": [ + "Storage: Janitor Closet" + ], + "Werewolf": [ + "Information Plaque: (Werewolf) Lycanthropy" + ], + "Pegasus": [ + "Information Plaque: (Pegasus) Cyclops" + ], "Anansi": [ - "Puzzle Solved Anansi Musicbox", + "Puzzle Solved Anansi Music Box", "Flashback Memory Obtained Ancient Astrology", - "Accessible: Storage: Skeleton", - "Accessible: Storage: Anansi", + "Storage: Skeleton", + "Storage: Anansi Music Box", "Information Plaque: (Anansi) African Creation Myth", "Information Plaque: (Anansi) Apophis the Serpent", - "Information Plaque: (Anansi) Death", - "Information Plaque: (Pegasus) Cyclops", - "Information Plaque: (Werewolf) Lycanthropy" + "Information Plaque: (Anansi) Death" ], "Torture": [ "Puzzle Solved Gallows", - "Accessible: Storage: Gallows", - "Final Riddle: Guillotine Dropped", + "Storage: Gallows", "Puzzle Hint Found: Gallows Information Plaque", "Information Plaque: (Torture) Guillotine" ], + "Guillotine": [ + "Final Riddle: Guillotine Dropped" + ], "Puzzle Room Mastermind": [ "Puzzle Solved Mastermind", "Puzzle Hint Found: Mastermind Information Plaque" @@ -287,29 +327,8 @@ "Puzzle Room Marbles": [ "Puzzle Solved Marble Flipper" ], - "Prehistoric": [ - "Information Plaque: (Prehistoric) Bronze Unicorn", - "Information Plaque: (Prehistoric) Griffin", - "Information Plaque: (Prehistoric) Eagles Nest", - "Information Plaque: (Prehistoric) Large Spider", - "Information Plaque: (Prehistoric) Starfish", - "Accessible: Storage: Eagles Nest" - ], - "Tar River": [ - "Accessible: Storage: Tar River", - "Information Plaque: (Underground Maze) Dero" - ], - "Theater": [ - "Accessible: Storage: Theater" - ], - "Greenhouse": [ - "Accessible: Storage: Greenhouse" - ], - "Janitor Closet": [ - "Accessible: Storage: Janitor Closet" - ], - "Skull Dial Bridge": [ - "Accessible: Storage: Skull Bridge", + "Skull Bridge": [ + "Storage: Skull Bridge", "Puzzle Solved Skull Dial Door" ], "Water Capture": [ @@ -338,6 +357,9 @@ ], "Metal Capture": [ "Ixupi Captured Metal" + ], + "Victory": [ + "Mystery Solved" ] } } diff --git a/worlds/shivers/data/regions.json b/worlds/shivers/data/regions.json index aeb5aa737366..36eaa7874cb9 100644 --- a/worlds/shivers/data/regions.json +++ b/worlds/shivers/data/regions.json @@ -4,22 +4,25 @@ ["Registry", ["To Outside From Registry"]], ["Outside", ["To Underground Tunnels From Outside", "To Lobby From Outside"]], ["Underground Tunnels", ["To Underground Lake From Underground Tunnels", "To Outside From Underground"]], - ["Underground Lake", ["To Underground Tunnels From Underground Lake", "To Underground Blue Tunnels From Underground Lake"]], + ["Underground Lake", ["To Underground Tunnels From Underground Lake", "To Windlenot's Body From Underground Lake", "To Underground Blue Tunnels From Underground Lake"]], + ["Windlenot's Body", ["To Underground Lake From Windlenot's Body"]], ["Underground Blue Tunnels", ["To Underground Lake From Underground Blue Tunnels", "To Office Elevator From Underground Blue Tunnels"]], ["Office Elevator", ["To Underground Blue Tunnels From Office Elevator","To Office From Office Elevator"]], ["Office", ["To Office Elevator From Office", "To Workshop", "To Lobby From Office", "To Bedroom Elevator From Office", "To Ash Capture From Office"]], ["Workshop", ["To Office From Workshop", "To Wood Capture From Workshop"]], ["Bedroom Elevator", ["To Office From Bedroom Elevator", "To Bedroom"]], ["Bedroom", ["To Bedroom Elevator From Bedroom", "To Metal Capture From Bedroom"]], - ["Lobby", ["To Office From Lobby", "To Library From Lobby", "To Theater From Lobby", "To Prehistoric From Lobby", "To Egypt From Lobby", "To Tar River From Lobby", "To Outside From Lobby", "To Water Capture From Lobby", "To Crystal Capture From Lobby"]], + ["Lobby", ["To Office From Lobby", "To Library From Lobby", "To Theater From Lobby", "To Prehistoric From Lobby", "To Egypt From Lobby", "To Tar River From Lobby", "To Outside From Lobby", "To Water Capture From Lobby", "To Crystal Capture From Lobby", "To Victory"]], ["Library", ["To Lobby From Library", "To Maintenance Tunnels From Library", "To Wax Capture From Library"]], - ["Maintenance Tunnels", ["To Library From Maintenance Tunnels", "To Three Floor Elevator From Maintenance Tunnels", "To Generator"]], + ["Maintenance Tunnels", ["To Library From Maintenance Tunnels", "To Three Floor Elevator From Maintenance Tunnels", "To Generator From Maintenance Tunnels"]], ["Generator", ["To Maintenance Tunnels From Generator"]], - ["Theater", ["To Lobby From Theater", "To Theater Back Hallways From Theater"]], - ["Theater Back Hallways", ["To Theater From Theater Back Hallways", "To Clock Tower Staircase From Theater Back Hallways", "To Maintenance Tunnels From Theater Back Hallways", "To Projector Room"]], - ["Clock Tower Staircase", ["To Theater Back Hallways From Clock Tower Staircase", "To Clock Tower"]], + ["Beth's Body", ["To Generator From Beth's Body"]], + ["Theater", ["To Lobby From Theater", "To Theater Back Hallway From Theater"]], + ["Theater Back Hallway", ["To Theater From Theater Back Hallway", "To Clock Tower Staircase From Theater Back Hallway", "To Maintenance Tunnels From Theater Back Hallway", "To Projector Room"]], + ["Clock Tower Staircase", ["To Theater Back Hallway From Clock Tower Staircase", "To Clock Tower"]], + ["Clock Chains", ["To Clock Tower Staircase From Clock Chains"]], ["Clock Tower", ["To Clock Tower Staircase From Clock Tower"]], - ["Projector Room", ["To Theater Back Hallways From Projector Room", "To Metal Capture From Projector Room"]], + ["Projector Room", ["To Theater Back Hallway From Projector Room", "To Metal Capture From Projector Room"]], ["Prehistoric", ["To Lobby From Prehistoric", "To Greenhouse", "To Ocean From Prehistoric", "To Oil Capture From Prehistoric", "To Metal Capture From Prehistoric"]], ["Greenhouse", ["To Prehistoric From Greenhouse", "To Sand Capture From Greenhouse"]], ["Ocean", ["To Prehistoric From Ocean", "To Maze Staircase From Ocean", "To Crystal Capture From Ocean", "To Sand Capture From Ocean"]], @@ -28,22 +31,26 @@ ["Tar River", ["To Maze From Tar River", "To Lobby From Tar River", "To Oil Capture From Tar River"]], ["Egypt", ["To Lobby From Egypt", "To Burial From Egypt", "To Blue Maze From Egypt", "To Cloth Capture From Egypt"]], ["Burial", ["To Egypt From Burial", "To Shaman From Burial", "To Ash Capture From Burial", "To Cloth Capture From Burial"]], - ["Shaman", ["To Burial From Shaman", "To Gods Room", "To Wax Capture From Shaman"]], - ["Gods Room", ["To Shaman From Gods Room", "To Anansi From Gods Room", "To Wood Capture From Gods Room"]], - ["Anansi", ["To Gods Room From Anansi", "To Werewolf From Anansi", "To Wax Capture From Anansi", "To Wood Capture From Anansi"]], - ["Werewolf", ["To Anansi From Werewolf", "To Night Staircase From Werewolf"]], - ["Night Staircase", ["To Werewolf From Night Staircase", "To Janitor Closet", "To UFO"]], + ["Shaman", ["To Burial From Shaman", "To Gods Room From Shaman", "To Wax Capture From Shaman"]], + ["Gods Room", ["To Shaman From Gods Room", "To Anansi From Gods Room", "To Wood Capture From Gods Room", "To Norse Stone From Gods Room"]], + ["Norse Stone", ["To Gods Room From Norse Stone"]], + ["Anansi", ["To Gods Room From Anansi", "To Pegasus From Anansi", "To Wax Capture From Anansi"]], + ["Pegasus", ["To Anansi From Pegasus", "To Werewolf From Pegasus", "To Wood Capture From Pegasus"]], + ["Werewolf", ["To Pegasus From Werewolf", "To Night Staircase From Werewolf"]], + ["Night Staircase", ["To Werewolf From Night Staircase", "To Janitor Closet", "To UFO From Night Staircase"]], ["Janitor Closet", ["To Night Staircase From Janitor Closet", "To Water Capture From Janitor Closet", "To Cloth Capture From Janitor Closet"]], - ["UFO", ["To Night Staircase From UFO", "To Inventions From UFO"]], + ["UFO", ["To Night Staircase From UFO", "To Orrery From UFO", "To Inventions From UFO"]], + ["Orrery", ["To UFO From Orrery"]], ["Blue Maze", ["To Egypt From Blue Maze", "To Three Floor Elevator From Blue Maze Bottom", "To Three Floor Elevator From Blue Maze Top", "To Fortune Teller", "To Inventions From Blue Maze", "To Wood Capture From Blue Maze"]], ["Three Floor Elevator", ["To Maintenance Tunnels From Three Floor Elevator", "To Blue Maze From Three Floor Elevator"]], ["Fortune Teller", ["To Blue Maze From Fortune Teller"]], ["Inventions", ["To Blue Maze From Inventions", "To UFO From Inventions", "To Torture From Inventions"]], ["Torture", ["To Inventions From Torture", "To Puzzle Room Mastermind From Torture"]], + ["Guillotine", ["To Torture From Guillotine"]], ["Puzzle Room Mastermind", ["To Torture", "To Puzzle Room Marbles From Puzzle Room Mastermind"]], - ["Puzzle Room Marbles", ["To Puzzle Room Mastermind From Puzzle Room Marbles", "To Skull Dial Bridge From Puzzle Room Marbles"]], - ["Skull Dial Bridge", ["To Puzzle Room Marbles From Skull Dial Bridge", "To Slide Room"]], - ["Slide Room", ["To Skull Dial Bridge From Slide Room", "To Lobby From Slide Room"]], + ["Puzzle Room Marbles", ["To Puzzle Room Mastermind From Puzzle Room Marbles", "To Skull Bridge From Puzzle Room Marbles"]], + ["Skull Bridge", ["To Puzzle Room Marbles From Skull Bridge", "To Slide Room"]], + ["Slide Room", ["To Skull Bridge From Slide Room", "To Lobby From Slide Room"]], ["Water Capture", []], ["Wax Capture", []], ["Ash Capture", []], @@ -52,17 +59,20 @@ ["Wood Capture", []], ["Crystal Capture", []], ["Sand Capture", []], - ["Metal Capture", []] + ["Metal Capture", []], + ["Victory", []] ], "mandatory_connections": [ - ["To Registry", "Registry"], + ["To Registry", "Registry"], ["To Outside From Registry", "Outside"], ["To Outside From Underground", "Outside"], ["To Outside From Lobby", "Outside"], ["To Underground Tunnels From Outside", "Underground Tunnels"], ["To Underground Tunnels From Underground Lake", "Underground Tunnels"], ["To Underground Lake From Underground Tunnels", "Underground Lake"], + ["To Underground Lake From Windlenot's Body", "Underground Lake"], ["To Underground Lake From Underground Blue Tunnels", "Underground Lake"], + ["To Windlenot's Body From Underground Lake", "Windlenot's Body"], ["To Underground Blue Tunnels From Underground Lake", "Underground Blue Tunnels"], ["To Underground Blue Tunnels From Office Elevator", "Underground Blue Tunnels"], ["To Office Elevator From Underground Blue Tunnels", "Office Elevator"], @@ -86,7 +96,7 @@ ["To Library From Lobby", "Library"], ["To Library From Maintenance Tunnels", "Library"], ["To Theater From Lobby", "Theater" ], - ["To Theater From Theater Back Hallways", "Theater"], + ["To Theater From Theater Back Hallway", "Theater"], ["To Prehistoric From Lobby", "Prehistoric"], ["To Prehistoric From Greenhouse", "Prehistoric"], ["To Prehistoric From Ocean", "Prehistoric"], @@ -96,15 +106,17 @@ ["To Maintenance Tunnels From Generator", "Maintenance Tunnels"], ["To Maintenance Tunnels From Three Floor Elevator", "Maintenance Tunnels"], ["To Maintenance Tunnels From Library", "Maintenance Tunnels"], - ["To Maintenance Tunnels From Theater Back Hallways", "Maintenance Tunnels"], + ["To Maintenance Tunnels From Theater Back Hallway", "Maintenance Tunnels"], ["To Three Floor Elevator From Maintenance Tunnels", "Three Floor Elevator"], ["To Three Floor Elevator From Blue Maze Bottom", "Three Floor Elevator"], ["To Three Floor Elevator From Blue Maze Top", "Three Floor Elevator"], - ["To Generator", "Generator"], - ["To Theater Back Hallways From Theater", "Theater Back Hallways"], - ["To Theater Back Hallways From Clock Tower Staircase", "Theater Back Hallways"], - ["To Theater Back Hallways From Projector Room", "Theater Back Hallways"], - ["To Clock Tower Staircase From Theater Back Hallways", "Clock Tower Staircase"], + ["To Generator From Maintenance Tunnels", "Generator"], + ["To Generator From Beth's Body", "Generator"], + ["To Theater Back Hallway From Theater", "Theater Back Hallway"], + ["To Theater Back Hallway From Clock Tower Staircase", "Theater Back Hallway"], + ["To Theater Back Hallway From Projector Room", "Theater Back Hallway"], + ["To Clock Tower Staircase From Theater Back Hallway", "Clock Tower Staircase"], + ["To Clock Tower Staircase From Clock Chains", "Clock Tower Staircase"], ["To Clock Tower Staircase From Clock Tower", "Clock Tower Staircase"], ["To Projector Room", "Projector Room"], ["To Clock Tower", "Clock Tower"], @@ -125,30 +137,37 @@ ["To Blue Maze From Egypt", "Blue Maze"], ["To Shaman From Burial", "Shaman"], ["To Shaman From Gods Room", "Shaman"], - ["To Gods Room", "Gods Room" ], + ["To Gods Room From Shaman", "Gods Room" ], + ["To Gods Room From Norse Stone", "Gods Room" ], ["To Gods Room From Anansi", "Gods Room"], + ["To Norse Stone From Gods Room", "Norse Stone" ], ["To Anansi From Gods Room", "Anansi"], - ["To Anansi From Werewolf", "Anansi"], - ["To Werewolf From Anansi", "Werewolf"], + ["To Anansi From Pegasus", "Anansi"], + ["To Pegasus From Anansi", "Pegasus"], + ["To Pegasus From Werewolf", "Pegasus"], + ["To Werewolf From Pegasus", "Werewolf"], ["To Werewolf From Night Staircase", "Werewolf"], ["To Night Staircase From Werewolf", "Night Staircase"], ["To Night Staircase From Janitor Closet", "Night Staircase"], ["To Night Staircase From UFO", "Night Staircase"], ["To Janitor Closet", "Janitor Closet"], - ["To UFO", "UFO"], + ["To UFO From Night Staircase", "UFO"], + ["To UFO From Orrery", "UFO"], ["To UFO From Inventions", "UFO"], + ["To Orrery From UFO", "Orrery"], ["To Inventions From UFO", "Inventions"], ["To Inventions From Blue Maze", "Inventions"], ["To Inventions From Torture", "Inventions"], ["To Fortune Teller", "Fortune Teller"], ["To Torture", "Torture"], + ["To Torture From Guillotine", "Torture"], ["To Torture From Inventions", "Torture"], ["To Puzzle Room Mastermind From Torture", "Puzzle Room Mastermind"], ["To Puzzle Room Mastermind From Puzzle Room Marbles", "Puzzle Room Mastermind"], ["To Puzzle Room Marbles From Puzzle Room Mastermind", "Puzzle Room Marbles"], - ["To Puzzle Room Marbles From Skull Dial Bridge", "Puzzle Room Marbles"], - ["To Skull Dial Bridge From Puzzle Room Marbles", "Skull Dial Bridge"], - ["To Skull Dial Bridge From Slide Room", "Skull Dial Bridge"], + ["To Puzzle Room Marbles From Skull Bridge", "Puzzle Room Marbles"], + ["To Skull Bridge From Puzzle Room Marbles", "Skull Bridge"], + ["To Skull Bridge From Slide Room", "Skull Bridge"], ["To Slide Room", "Slide Room"], ["To Wax Capture From Library", "Wax Capture"], ["To Wax Capture From Shaman", "Wax Capture"], @@ -164,7 +183,7 @@ ["To Cloth Capture From Janitor Closet", "Cloth Capture"], ["To Wood Capture From Workshop", "Wood Capture"], ["To Wood Capture From Gods Room", "Wood Capture"], - ["To Wood Capture From Anansi", "Wood Capture"], + ["To Wood Capture From Pegasus", "Wood Capture"], ["To Wood Capture From Blue Maze", "Wood Capture"], ["To Crystal Capture From Lobby", "Crystal Capture"], ["To Crystal Capture From Ocean", "Crystal Capture"], @@ -172,6 +191,7 @@ ["To Sand Capture From Ocean", "Sand Capture"], ["To Metal Capture From Bedroom", "Metal Capture"], ["To Metal Capture From Projector Room", "Metal Capture"], - ["To Metal Capture From Prehistoric", "Metal Capture"] + ["To Metal Capture From Prehistoric", "Metal Capture"], + ["To Victory", "Victory"] ] } diff --git a/worlds/shivers/docs/en_Shivers.md b/worlds/shivers/docs/en_Shivers.md index 2c56152a7a0c..9490b577bdd0 100644 --- a/worlds/shivers/docs/en_Shivers.md +++ b/worlds/shivers/docs/en_Shivers.md @@ -27,5 +27,4 @@ Victory is achieved when the player has captured the required number Ixupi set i ## Encountered a bug? -Please contact GodlFire on Discord for bugs related to Shivers world generation.
-Please contact GodlFire or mouse on Discord for bugs related to the Shivers Randomizer. +Please contact GodlFire or Cynbel_Terreus on Discord for bugs related to Shivers world generation or the Shivers Randomizer. From ca1b3df45b0c9939dffa9cada01dee3c23291911 Mon Sep 17 00:00:00 2001 From: Kory Dondzila Date: Fri, 27 Dec 2024 17:38:01 -0500 Subject: [PATCH 13/38] Shivers: Follow on PR to cleanup options #4401 --- worlds/shivers/Options.py | 80 +++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 46 deletions(-) diff --git a/worlds/shivers/Options.py b/worlds/shivers/Options.py index 2e68c4beecc0..5aa6c207cfc1 100644 --- a/worlds/shivers/Options.py +++ b/worlds/shivers/Options.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from Options import ( - Choice, DefaultOnToggle, ItemDict, ItemSet, LocationSet, OptionGroup, PerGameCommonOptions, Range, Toggle, + Choice, DefaultOnToggle, ExcludeLocations, LocalItems, NonLocalItems, OptionGroup, PerGameCommonOptions, + PriorityLocations, Range, StartHints, StartInventory, StartLocationHints, Toggle, ) from . import ItemType, item_table from .Constants import location_info @@ -129,53 +130,38 @@ class PuzzleCollectBehavior(Choice): valid_location_keys = [name for name in location_info["all_locations"] if name != "Mystery Solved"] -class LocalItems(ItemSet): - """Forces these items to be in their native world.""" - display_name = "Local Items" - rich_text_doc = True +class ShiversLocalItems(LocalItems): + __doc__ = LocalItems.__doc__ valid_keys = valid_item_keys -class NonLocalItems(ItemSet): - """Forces these items to be outside their native world.""" - display_name = "Non-local Items" - rich_text_doc = True +class ShiversNonLocalItems(NonLocalItems): + __doc__ = NonLocalItems.__doc__ valid_keys = valid_item_keys -class StartInventory(ItemDict): - """Start with these items.""" - verify_item_name = True - display_name = "Start Inventory" - rich_text_doc = True +class ShiversStartInventory(StartInventory): + __doc__ = StartInventory.__doc__ valid_keys = valid_item_keys -class StartHints(ItemSet): - """Start with these item's locations prefilled into the ``!hint`` command.""" - display_name = "Start Hints" - rich_text_doc = True +class ShiversStartHints(StartHints): + __doc__ = StartHints.__doc__ valid_keys = valid_item_keys -class StartLocationHints(LocationSet): - """Start with these locations and their item prefilled into the ``!hint`` command.""" - display_name = "Start Location Hints" - rich_text_doc = True +class ShiversStartLocationHints(StartLocationHints): + __doc__ = StartLocationHints.__doc__ valid_keys = valid_location_keys -class ExcludeLocations(LocationSet): - """Prevent these locations from having an important item.""" - display_name = "Excluded Locations" - rich_text_doc = True +class ShiversExcludeLocations(ExcludeLocations): + __doc__ = ExcludeLocations.__doc__ valid_keys = valid_location_keys -class PriorityLocations(LocationSet): - """Prevent these locations from having an unimportant item.""" - display_name = "Priority Locations" - rich_text_doc = True +class ShiversPriorityLocations(PriorityLocations): + __doc__ = PriorityLocations.__doc__ valid_keys = valid_location_keys @@ -192,23 +178,25 @@ class ShiversOptions(PerGameCommonOptions): location_pot_pieces: LocationPotPieces full_pots: FullPots puzzle_collect_behavior: PuzzleCollectBehavior - local_items: LocalItems - non_local_items: NonLocalItems - start_inventory: StartInventory - start_hints: StartHints - start_location_hints: StartLocationHints - exclude_locations: ExcludeLocations - priority_locations: PriorityLocations + local_items: ShiversLocalItems + non_local_items: ShiversNonLocalItems + start_inventory: ShiversStartInventory + start_hints: ShiversStartHints + start_location_hints: ShiversStartLocationHints + exclude_locations: ShiversExcludeLocations + priority_locations: ShiversPriorityLocations shivers_option_groups = [ - OptionGroup("Item & Location Options", [ - LocalItems, - NonLocalItems, - StartInventory, - StartHints, - StartLocationHints, - ExcludeLocations, - PriorityLocations - ], True), + OptionGroup( + "Item & Location Options", [ + ShiversLocalItems, + ShiversNonLocalItems, + ShiversStartInventory, + ShiversStartHints, + ShiversStartLocationHints, + ShiversExcludeLocations, + ShiversPriorityLocations + ], True, + ), ] From 2065246186a56a345593232c928afff3e587e34b Mon Sep 17 00:00:00 2001 From: CaitSith2 Date: Sun, 29 Dec 2024 11:13:34 -0800 Subject: [PATCH 14/38] Factorio: Make it possible to use rocket part in blueprint parameterization. (#4396) This allows for example, making a blueprint of your rocket silo with requester chests specifying a request for the 2-8 rocket part ingredients needed to build the rocket. --- worlds/factorio/data/mod_template/data-final-fixes.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index dc068c4f62aa..60a56068b788 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -1,6 +1,7 @@ {% from "macros.lua" import dict_to_recipe, variable_to_lua %} -- this file gets written automatically by the Archipelago Randomizer and is in its raw form a Jinja2 Template require('lib') +data.raw["item"]["rocket-part"].hidden = false data.raw["rocket-silo"]["rocket-silo"].fluid_boxes = { { production_type = "input", From fa95ae4b24fb2b954e9a9923d3672b2f45ce9fc4 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 29 Dec 2024 20:55:40 +0100 Subject: [PATCH 15/38] Factorio: require version that fixes a randomizer exploit (#4391) --- worlds/factorio/Mod.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index 7dee04afbee3..8ea0b24c3d27 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -37,8 +37,8 @@ "description": "Integration client for the Archipelago Randomizer", "factorio_version": "2.0", "dependencies": [ - "base >= 2.0.15", - "? quality >= 2.0.15", + "base >= 2.0.28", + "? quality >= 2.0.28", "! space-age", "? science-not-invited", "? factory-levels" From 0de1369ec5dd8f37f8b31148f7c354804a3f8876 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 29 Dec 2024 20:56:41 +0100 Subject: [PATCH 16/38] Factorio: hide hidden vanilla techs in factoriopedia too (#4332) --- worlds/factorio/data/mod_template/data-final-fixes.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 60a56068b788..8092062bc3f2 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -163,6 +163,7 @@ data.raw["ammo"]["artillery-shell"].stack_size = 10 {# each randomized tech gets set to be invisible, with new nodes added that trigger those #} {%- for original_tech_name in base_tech_table -%} technologies["{{ original_tech_name }}"].hidden = true +technologies["{{ original_tech_name }}"].hidden_in_factoriopedia = true {% endfor %} {%- for location, item in locations %} {#- the tech researched by the local player #} From 8dbecf3d57fe4dbcabe7bb3068104d074193b7f7 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 30 Dec 2024 00:50:39 +0100 Subject: [PATCH 17/38] The Witness: Make location order in the spoiler log deterministic (#3895) * Fix location order * Update worlds/witness/data/static_logic.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/witness/data/static_logic.py | 2 ++ worlds/witness/regions.py | 29 +++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index 58f2e894e849..6cc4e1431d07 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -106,6 +106,7 @@ def read_logic_file(self, lines: List[str]) -> None: "entityType": location_id, "locationType": None, "area": current_area, + "order": len(self.ENTITIES_BY_HEX), } self.ENTITIES_BY_NAME[self.ENTITIES_BY_HEX[entity_hex]["checkName"]] = self.ENTITIES_BY_HEX[entity_hex] @@ -186,6 +187,7 @@ def read_logic_file(self, lines: List[str]) -> None: "entityType": entity_type, "locationType": location_type, "area": current_area, + "order": len(self.ENTITIES_BY_HEX), } self.ENTITY_ID_TO_NAME[entity_hex] = full_entity_name diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 1df438f68b0d..a1f7df8a310c 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -114,7 +114,7 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic if k not in player_logic.UNREACHABLE_REGIONS } - event_locations_per_region = defaultdict(list) + event_locations_per_region = defaultdict(dict) for event_location, event_item_and_entity in player_logic.EVENT_ITEM_PAIRS.items(): region = static_witness_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["region"] @@ -122,20 +122,33 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic region_name = "Entry" else: region_name = region["name"] - event_locations_per_region[region_name].append(event_location) + order = self.reference_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["order"] + event_locations_per_region[region_name][event_location] = order for region_name, region in regions_to_create.items(): - locations_for_this_region = [ - self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] for panel in region["entities"] - if self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] - in self.player_locations.CHECK_LOCATION_TABLE + location_entities_for_this_region = [ + self.reference_logic.ENTITIES_BY_HEX[entity] for entity in region["entities"] ] + locations_for_this_region = { + entity["checkName"]: entity["order"] for entity in location_entities_for_this_region + if entity["checkName"] in self.player_locations.CHECK_LOCATION_TABLE + } - locations_for_this_region += event_locations_per_region[region_name] + events = event_locations_per_region[region_name] + locations_for_this_region.update(events) + + # First, sort by keys. + locations_for_this_region = dict(sorted(locations_for_this_region.items())) + + # Then, sort by game order (values) + locations_for_this_region = dict(sorted( + locations_for_this_region.items(), + key=lambda location_name_and_order: location_name_and_order[1] + )) all_locations = all_locations | set(locations_for_this_region) - new_region = create_region(world, region_name, self.player_locations, locations_for_this_region) + new_region = create_region(world, region_name, self.player_locations, list(locations_for_this_region)) regions_by_name[region_name] = new_region From c4bbcf989036ffe698fe11179defcbd107dc55e8 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 31 Dec 2024 04:57:09 +0000 Subject: [PATCH 18/38] TUNIC: Add relics and abilities to the item pool in deterministic order (#4411) --- worlds/tunic/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index 29dbf150125c..8525a3fc437d 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -284,12 +284,14 @@ def remove_filler(amount: int) -> None: remove_filler(items_to_create[gold_hexagon]) - for hero_relic in item_name_groups["Hero Relics"]: + # Sort for deterministic order + for hero_relic in sorted(item_name_groups["Hero Relics"]): tunic_items.append(self.create_item(hero_relic, ItemClassification.useful)) items_to_create[hero_relic] = 0 if not self.options.ability_shuffling: - for page in item_name_groups["Abilities"]: + # Sort for deterministic order + for page in sorted(item_name_groups["Abilities"]): if items_to_create[page] > 0: tunic_items.append(self.create_item(page, ItemClassification.useful)) items_to_create[page] = 0 From 3c9270d8029ac5445d6055cac5c9a464b3a33ba8 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 31 Dec 2024 14:02:02 +0000 Subject: [PATCH 19/38] FFMQ: Create itempool in deterministic order (#4413) --- worlds/ffmq/Items.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/ffmq/Items.py b/worlds/ffmq/Items.py index f1c102d34ef8..31453a0fef29 100644 --- a/worlds/ffmq/Items.py +++ b/worlds/ffmq/Items.py @@ -260,7 +260,8 @@ def add_item(item_name): items.append(i) for item_group in ("Key Items", "Spells", "Armors", "Helms", "Shields", "Accessories", "Weapons"): - for item in self.item_name_groups[item_group]: + # Sort for deterministic order + for item in sorted(self.item_name_groups[item_group]): add_item(item) if self.options.brown_boxes == "include": From 6e59ee2926410ed791cbcd6413ffa0b158974a94 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 31 Dec 2024 14:16:29 +0000 Subject: [PATCH 20/38] Zork Grand Inquisitor: Precollect Start with Hotspot Items in deterministic order (#4412) --- worlds/zork_grand_inquisitor/world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/zork_grand_inquisitor/world.py b/worlds/zork_grand_inquisitor/world.py index a93f2c2134c1..3698ad7f8960 100644 --- a/worlds/zork_grand_inquisitor/world.py +++ b/worlds/zork_grand_inquisitor/world.py @@ -176,7 +176,7 @@ def create_items(self) -> None: if start_with_hotspot_items: item: ZorkGrandInquisitorItems - for item in items_with_tag(ZorkGrandInquisitorTags.HOTSPOT): + for item in sorted(items_with_tag(ZorkGrandInquisitorTags.HOTSPOT), key=lambda item: item.name): self.multiworld.push_precollected(self.create_item(item.value)) def create_item(self, name: str) -> ZorkGrandInquisitorItem: From 917335ec54210c4b368cb3ba7b202e133a1c12c9 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Wed, 1 Jan 2025 02:02:18 +0100 Subject: [PATCH 21/38] Core: it's 2025 (#4417) --- LICENSE | 2 +- WebHostLib/templates/islandFooter.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 40716cff4275..60d31b7b7de8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Copyright (c) 2017 LLCoolDave -Copyright (c) 2022 Berserker66 +Copyright (c) 2025 Berserker66 Copyright (c) 2022 CaitSith2 Copyright (c) 2021 LegendaryLinux diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 08cf227990b8..7de14f0d827c 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}