From 6e6bb491596beed7831a3a13c1b63c1feacf0d8b Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sat, 1 Jun 2024 23:11:28 +0200 Subject: [PATCH] The Witness: Automatic Postgame & Disabled Panels Calculation (#2698) * Refactor postgame code to be more readable * Change all references to options to strings * oops * Fix some outdated code related to yaml-disabled EPs * Small fixes to short/longbox stuff (thanks Medic) * comment * fix duplicate * Removed triplicate lmfao * Better comment * added another 'unfun' postgame consideration * comment * more option strings * oops * Remove an unnecessary comparison * another string missed * New classification changes (Credit: Exempt-Medic) * Don't need to pass world * Comments * Replace it with another magic system because why not at this point :DDDDDD * oops * Oops * Another was missed * Make events conditions. Disable_Non_Randomized will no longer just 'have all events' * What the fuck? Has this just always been broken? * Don't have boolean function with 'not' in the name * Another useful classification * slight code refactor * Funny haha booleans * This would create a really bad merge error * I can't believe this actually kind of works * And here's the punchline. + some bugfixes * Comment dat code * Comments galore * LMAO OOPS * so nice I did it twice * debug x2 * Careful * Add more comments * That comment is a bit unnecessary now * Fix overriding region connections * Correct a comment * Correct again * Rename variable * Idk I guess this is in this branch now * More tweaking of postgame & comments * This is commit just exists to fix that grammar error * I think I can just fucking delete this now??? * Forgot to reset something here * Delete dead codepath * Obelisk Keys were getting yote erroneously * More comments * Fix duplicate connections * Oopsington III * performance improvements & cleanup * More rules cleanup and performance improvements * Oh cool I can do this huh * Okay but this is even more swag tho * Lazy eval * remove some implicit checks * Is this too magical yet * more guard magic * Maaaaaaaagiccccccccc * Laaaaaaaaaaaaaaaazzzzzzyyyyyyyyyyy * Make it docstring * Newline bc I like that better * this is a little spooky lol * lol * Wait * spoO * Better variable name and comment * Improved comment again * better API * oops I deleted a deepcopy * lol help * Help??? * player_regionsns lmao * Add some comments * Make doors disabled properly again. I hope this works * Don't disable lasers * Omega oops * Make Floor 2 Exit not exist * Make a fix that's warps compatible * I think this was an oversight, I tested a seed and it seems to have the same result * This is definitely less Violet than before * Does this feel more violet lol * Exception if a laser gets disabled, cleanup * Ruff * >:( * consistent utils import * Make autopostgame more reviewable (hopefully) * more reviewability * WitnessRule * replace another instance of it * lint * style * comment * found the bug * Move comment * Get rid of cache and ugly allow_victory * comments and lint --- worlds/witness/data/WitnessLogic.txt | 4 +- worlds/witness/data/WitnessLogicExpert.txt | 4 +- worlds/witness/data/WitnessLogicVanilla.txt | 4 +- .../Caves_Except_Path_To_Challenge.txt} | 0 .../Exclusions/Disable_Unrandomized.txt | 14 - .../data/settings/Exclusions/Vaults.txt | 23 - .../settings/Postgame/Beyond_Challenge.txt | 4 - .../Postgame/Bottom_Floor_Discard.txt | 2 - .../Bottom_Floor_Discard_NonDoors.txt | 6 - .../settings/Postgame/Challenge_Vault_Box.txt | 22 - .../data/settings/Postgame/Mountain_Lower.txt | 27 - .../data/settings/Postgame/Mountain_Upper.txt | 41 -- .../settings/Postgame/Path_To_Challenge.txt | 30 - worlds/witness/data/static_logic.py | 51 +- worlds/witness/data/utils.py | 66 +- worlds/witness/hints.py | 4 +- worlds/witness/player_logic.py | 580 ++++++++++++------ worlds/witness/regions.py | 63 +- worlds/witness/rules.py | 186 ++++-- 19 files changed, 628 insertions(+), 503 deletions(-) rename worlds/witness/data/settings/{Postgame/Caves.txt => Exclusions/Caves_Except_Path_To_Challenge.txt} (100%) delete mode 100644 worlds/witness/data/settings/Postgame/Beyond_Challenge.txt delete mode 100644 worlds/witness/data/settings/Postgame/Bottom_Floor_Discard.txt delete mode 100644 worlds/witness/data/settings/Postgame/Bottom_Floor_Discard_NonDoors.txt delete mode 100644 worlds/witness/data/settings/Postgame/Challenge_Vault_Box.txt delete mode 100644 worlds/witness/data/settings/Postgame/Mountain_Lower.txt delete mode 100644 worlds/witness/data/settings/Postgame/Mountain_Upper.txt delete mode 100644 worlds/witness/data/settings/Postgame/Path_To_Challenge.txt diff --git a/worlds/witness/data/WitnessLogic.txt b/worlds/witness/data/WitnessLogic.txt index 6a89a8b060e8..272ed176e342 100644 --- a/worlds/witness/data/WitnessLogic.txt +++ b/worlds/witness/data/WitnessLogic.txt @@ -1028,7 +1028,7 @@ Mountain Floor 1 Bridge (Mountain Floor 1) - Mountain Floor 1 At Door - TrueOneW Mountain Floor 1 At Door (Mountain Floor 1) - Mountain Floor 2 - 0x09E54: Door - 0x09E54 (Exit) - 0x09EAF & 0x09F6E & 0x09E6B & 0x09E7B -Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 0x09FFB - Mountain Floor 2 Beyond Bridge - 0x09E86 - Mountain Floor 2 At Door - 0x09ED8 & 0x09E86 - Mountain Pink Bridge EP - TrueOneWay: +Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 0x09FFB - Mountain Floor 2 Beyond Bridge - 0x09E86 - Mountain Floor 2 Above The Abyss - True - Mountain Pink Bridge EP - TrueOneWay: 158426 - 0x09FD3 (Near Row 1) - True - Stars & Colored Squares & Stars + Same Colored Symbol 158427 - 0x09FD4 (Near Row 2) - 0x09FD3 - Stars & Colored Squares & Stars + Same Colored Symbol 158428 - 0x09FD6 (Near Row 3) - 0x09FD4 - Stars & Colored Squares & Stars + Same Colored Symbol @@ -1036,7 +1036,7 @@ Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 158430 - 0x09FD8 (Near Row 5) - 0x09FD7 - Symmetry & Colored Dots Door - 0x09FFB (Staircase Near) - 0x09FD8 -Mountain Floor 2 At Door (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD: +Mountain Floor 2 Above The Abyss (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD & 0x09ED8 & 0x09E86: Door - 0x09EDD (Elevator Room Entry) - 0x09ED8 & 0x09E86 Mountain Floor 2 Light Bridge Room Near (Mountain Floor 2): diff --git a/worlds/witness/data/WitnessLogicExpert.txt b/worlds/witness/data/WitnessLogicExpert.txt index 7a8c37ac309e..63e7e36c243e 100644 --- a/worlds/witness/data/WitnessLogicExpert.txt +++ b/worlds/witness/data/WitnessLogicExpert.txt @@ -1028,7 +1028,7 @@ Mountain Floor 1 Bridge (Mountain Floor 1) - Mountain Floor 1 At Door - TrueOneW Mountain Floor 1 At Door (Mountain Floor 1) - Mountain Floor 2 - 0x09E54: Door - 0x09E54 (Exit) - 0x09EAF & 0x09F6E & 0x09E6B & 0x09E7B -Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 0x09FFB - Mountain Floor 2 Beyond Bridge - 0x09E86 - Mountain Floor 2 At Door - 0x09ED8 & 0x09E86 - Mountain Pink Bridge EP - TrueOneWay: +Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 0x09FFB - Mountain Floor 2 Beyond Bridge - 0x09E86 - Mountain Floor 2 Above The Abyss - True - Mountain Pink Bridge EP - TrueOneWay: 158426 - 0x09FD3 (Near Row 1) - True - Stars & Colored Squares & Stars + Same Colored Symbol 158427 - 0x09FD4 (Near Row 2) - 0x09FD3 - Stars & Triangles & Stars + Same Colored Symbol 158428 - 0x09FD6 (Near Row 3) - 0x09FD4 - Stars & Shapers & Negative Shapers & Stars + Same Colored Symbol @@ -1036,7 +1036,7 @@ Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 158430 - 0x09FD8 (Near Row 5) - 0x09FD7 - Stars & Stars + Same Colored Symbol & Rotated Shapers & Eraser Door - 0x09FFB (Staircase Near) - 0x09FD8 -Mountain Floor 2 At Door (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD: +Mountain Floor 2 Above The Abyss (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD & 0x09ED8 & 0x09E86: Door - 0x09EDD (Elevator Room Entry) - 0x09ED8 & 0x09E86 Mountain Floor 2 Light Bridge Room Near (Mountain Floor 2): diff --git a/worlds/witness/data/WitnessLogicVanilla.txt b/worlds/witness/data/WitnessLogicVanilla.txt index 84205030cc64..1aa9655361f9 100644 --- a/worlds/witness/data/WitnessLogicVanilla.txt +++ b/worlds/witness/data/WitnessLogicVanilla.txt @@ -1028,7 +1028,7 @@ Mountain Floor 1 Bridge (Mountain Floor 1) - Mountain Floor 1 At Door - TrueOneW Mountain Floor 1 At Door (Mountain Floor 1) - Mountain Floor 2 - 0x09E54: Door - 0x09E54 (Exit) - 0x09EAF & 0x09F6E & 0x09E6B & 0x09E7B -Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 0x09FFB - Mountain Floor 2 Beyond Bridge - 0x09E86 - Mountain Floor 2 At Door - 0x09ED8 & 0x09E86 - Mountain Pink Bridge EP - TrueOneWay: +Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 0x09FFB - Mountain Floor 2 Beyond Bridge - 0x09E86 - Mountain Floor 2 Above The Abyss - True - Mountain Pink Bridge EP - TrueOneWay: 158426 - 0x09FD3 (Near Row 1) - True - Colored Squares 158427 - 0x09FD4 (Near Row 2) - 0x09FD3 - Colored Squares & Dots 158428 - 0x09FD6 (Near Row 3) - 0x09FD4 - Stars & Colored Squares & Stars + Same Colored Symbol @@ -1036,7 +1036,7 @@ Mountain Floor 2 (Mountain Floor 2) - Mountain Floor 2 Light Bridge Room Near - 158430 - 0x09FD8 (Near Row 5) - 0x09FD7 - Colored Squares Door - 0x09FFB (Staircase Near) - 0x09FD8 -Mountain Floor 2 At Door (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD: +Mountain Floor 2 Above The Abyss (Mountain Floor 2) - Mountain Floor 2 Elevator Room - 0x09EDD & 0x09ED8 & 0x09E86: Door - 0x09EDD (Elevator Room Entry) - 0x09ED8 & 0x09E86 Mountain Floor 2 Light Bridge Room Near (Mountain Floor 2): diff --git a/worlds/witness/data/settings/Postgame/Caves.txt b/worlds/witness/data/settings/Exclusions/Caves_Except_Path_To_Challenge.txt similarity index 100% rename from worlds/witness/data/settings/Postgame/Caves.txt rename to worlds/witness/data/settings/Exclusions/Caves_Except_Path_To_Challenge.txt diff --git a/worlds/witness/data/settings/Exclusions/Disable_Unrandomized.txt b/worlds/witness/data/settings/Exclusions/Disable_Unrandomized.txt index 09c366cfaabd..3dfc34e8ad0a 100644 --- a/worlds/witness/data/settings/Exclusions/Disable_Unrandomized.txt +++ b/worlds/witness/data/settings/Exclusions/Disable_Unrandomized.txt @@ -134,17 +134,3 @@ Disabled Locations: 0x17E67 (Bunker UV Room 2) 0x09DE0 (Bunker Laser) 0x0A079 (Bunker Elevator Control) - -0x034A7 (Monastery Left Shutter EP) -0x034AD (Monastery Middle Shutter EP) -0x034AF (Monastery Right Shutter EP) -0x339B6 (Theater Eclipse EP) -0x33A29 (Theater Window EP) -0x33A2A (Theater Door EP) -0x33B06 (Theater Church EP) -0x3352F (Tutorial Gate EP) -0x33600 (Tutorial Patio Flowers EP) -0x035F5 (Bunker Tinted Door EP) -0x000D3 (Bunker Green Room Flowers EP) -0x33A20 (Theater Flowers EP) -0x03BE2 (Monastery Garden Left EP) diff --git a/worlds/witness/data/settings/Exclusions/Vaults.txt b/worlds/witness/data/settings/Exclusions/Vaults.txt index d9e5d28cd694..9eade5e52855 100644 --- a/worlds/witness/data/settings/Exclusions/Vaults.txt +++ b/worlds/witness/data/settings/Exclusions/Vaults.txt @@ -1,31 +1,8 @@ Disabled Locations: 0x033D4 (Outside Tutorial Vault) -0x03481 (Outside Tutorial Vault Box) -0x033D0 (Outside Tutorial Vault Door) 0x0CC7B (Desert Vault) -0x0339E (Desert Vault Box) -0x03444 (Desert Vault Door) 0x00AFB (Shipwreck Vault) -0x03535 (Shipwreck Vault Box) -0x17BB4 (Shipwreck Vault Door) 0x15ADD (Jungle Vault) -0x03702 (Jungle Vault Box) -0x15287 (Jungle Vault Door) 0x002A6 (Mountainside Vault) -0x03542 (Mountainside Vault Box) -0x00085 (Mountainside Vault Door) 0x2FAF6 (Tunnels Vault Box) 0x00815 (Theater Video Input) -0x03553 (Theater Tutorial Video) -0x03552 (Theater Desert Video) -0x0354E (Theater Jungle Video) -0x03549 (Theater Challenge Video) -0x0354F (Theater Shipwreck Video) -0x03545 (Theater Mountain Video) -0x03505 (Tutorial Gate Close) -0x339B6 (Theater clipse EP) -0x33A29 (Theater Window EP) -0x33A2A (Theater Door EP) -0x33B06 (Theater Church EP) -0x33A20 (Theater Flowers EP) -0x3352F (Tutorial Gate EP) diff --git a/worlds/witness/data/settings/Postgame/Beyond_Challenge.txt b/worlds/witness/data/settings/Postgame/Beyond_Challenge.txt deleted file mode 100644 index 5cd20b6a5e40..000000000000 --- a/worlds/witness/data/settings/Postgame/Beyond_Challenge.txt +++ /dev/null @@ -1,4 +0,0 @@ -Disabled Locations: -0x03549 (Challenge Video) - -0x339B6 (Eclipse EP) diff --git a/worlds/witness/data/settings/Postgame/Bottom_Floor_Discard.txt b/worlds/witness/data/settings/Postgame/Bottom_Floor_Discard.txt deleted file mode 100644 index 8f7d6a257a53..000000000000 --- a/worlds/witness/data/settings/Postgame/Bottom_Floor_Discard.txt +++ /dev/null @@ -1,2 +0,0 @@ -Disabled Locations: -0x17FA2 (Mountain Bottom Floor Discard) diff --git a/worlds/witness/data/settings/Postgame/Bottom_Floor_Discard_NonDoors.txt b/worlds/witness/data/settings/Postgame/Bottom_Floor_Discard_NonDoors.txt deleted file mode 100644 index 5ea7c578d8bf..000000000000 --- a/worlds/witness/data/settings/Postgame/Bottom_Floor_Discard_NonDoors.txt +++ /dev/null @@ -1,6 +0,0 @@ -Disabled Locations: -0x17FA2 (Mountain Bottom Floor Discard) -0x17F33 (Rock Open Door) -0x00FF8 (Caves Entry Panel) -0x334E1 (Rock Control) -0x2D77D (Caves Entry Door) diff --git a/worlds/witness/data/settings/Postgame/Challenge_Vault_Box.txt b/worlds/witness/data/settings/Postgame/Challenge_Vault_Box.txt deleted file mode 100644 index 8b431694b3b4..000000000000 --- a/worlds/witness/data/settings/Postgame/Challenge_Vault_Box.txt +++ /dev/null @@ -1,22 +0,0 @@ -Disabled Locations: -0x0356B (Challenge Vault Box) -0x04D75 (Vault Door) -0x0A332 (Start Timer) -0x0088E (Small Basic) -0x00BAF (Big Basic) -0x00BF3 (Square) -0x00C09 (Maze Map) -0x00CDB (Stars and Dots) -0x0051F (Symmetry) -0x00524 (Stars and Shapers) -0x00CD4 (Big Basic 2) -0x00CB9 (Choice Squares Right) -0x00CA1 (Choice Squares Middle) -0x00C80 (Choice Squares Left) -0x00C68 (Choice Squares 2 Right) -0x00C59 (Choice Squares 2 Middle) -0x00C22 (Choice Squares 2 Left) -0x034F4 (Maze Hidden 1) -0x034EC (Maze Hidden 2) -0x1C31A (Dots Pillar) -0x1C319 (Squares Pillar) diff --git a/worlds/witness/data/settings/Postgame/Mountain_Lower.txt b/worlds/witness/data/settings/Postgame/Mountain_Lower.txt deleted file mode 100644 index aecddec5adde..000000000000 --- a/worlds/witness/data/settings/Postgame/Mountain_Lower.txt +++ /dev/null @@ -1,27 +0,0 @@ -Disabled Locations: -0x17F93 (Elevator Discard) -0x09EEB (Elevator Control Panel) -0x09FC1 (Giant Puzzle Bottom Left) -0x09F8E (Giant Puzzle Bottom Right) -0x09F01 (Giant Puzzle Top Right) -0x09EFF (Giant Puzzle Top Left) -0x09FDA (Giant Puzzle) -0x09F89 (Exit Door) -0x01983 (Pillars Room Entry Left) -0x01987 (Pillars Room Entry Right) -0x0C141 (Pillars Room Entry Door) -0x0383A (Right Pillar 1) -0x09E56 (Right Pillar 2) -0x09E5A (Right Pillar 3) -0x33961 (Right Pillar 4) -0x0383D (Left Pillar 1) -0x0383F (Left Pillar 2) -0x03859 (Left Pillar 3) -0x339BB (Left Pillar 4) -0x3D9A6 (Elevator Door Closer Left) -0x3D9A7 (Elevator Door Close Right) -0x3C113 (Elevator Entry Left) -0x3C114 (Elevator Entry Right) -0x3D9AA (Back Wall Left) -0x3D9A8 (Back Wall Right) -0x3D9A9 (Elevator Start) diff --git a/worlds/witness/data/settings/Postgame/Mountain_Upper.txt b/worlds/witness/data/settings/Postgame/Mountain_Upper.txt deleted file mode 100644 index e2b0765f533c..000000000000 --- a/worlds/witness/data/settings/Postgame/Mountain_Upper.txt +++ /dev/null @@ -1,41 +0,0 @@ -Disabled Locations: -0x17C34 (Mountain Entry Panel) -0x09E39 (Light Bridge Controller) -0x09E7A (Right Row 1) -0x09E71 (Right Row 2) -0x09E72 (Right Row 3) -0x09E69 (Right Row 4) -0x09E7B (Right Row 5) -0x09E73 (Left Row 1) -0x09E75 (Left Row 2) -0x09E78 (Left Row 3) -0x09E79 (Left Row 4) -0x09E6C (Left Row 5) -0x09E6F (Left Row 6) -0x09E6B (Left Row 7) -0x33AF5 (Back Row 1) -0x33AF7 (Back Row 2) -0x09F6E (Back Row 3) -0x09EAD (Trash Pillar 1) -0x09EAF (Trash Pillar 2) -0x09E54 (Mountain Floor 1 Exit Door) -0x09FD3 (Near Row 1) -0x09FD4 (Near Row 2) -0x09FD6 (Near Row 3) -0x09FD7 (Near Row 4) -0x09FD8 (Near Row 5) -0x09FFB (Staircase Near Door) -0x09EDD (Elevator Room Entry Door) -0x09E86 (Light Bridge Controller Near) -0x09FCC (Far Row 1) -0x09FCE (Far Row 2) -0x09FCF (Far Row 3) -0x09FD0 (Far Row 4) -0x09FD1 (Far Row 5) -0x09FD2 (Far Row 6) -0x09E07 (Staircase Far Door) -0x09ED8 (Light Bridge Controller Far) - -0x09D63 (Pink Bridge EP) -0x09D5D (Yellow Bridge EP) -0x09D5E (Blue Bridge EP) diff --git a/worlds/witness/data/settings/Postgame/Path_To_Challenge.txt b/worlds/witness/data/settings/Postgame/Path_To_Challenge.txt deleted file mode 100644 index 3f9239cc4832..000000000000 --- a/worlds/witness/data/settings/Postgame/Path_To_Challenge.txt +++ /dev/null @@ -1,30 +0,0 @@ -Disabled Locations: -0x0356B (Vault Box) -0x04D75 (Vault Door) -0x17F33 (Rock Open Door) -0x00FF8 (Caves Entry Panel) -0x334E1 (Rock Control) -0x2D77D (Caves Entry Door) -0x09DD5 (Lone Pillar) -0x019A5 (Caves Pillar Door) -0x0A16E (Challenge Entry Panel) -0x0A19A (Challenge Entry Door) -0x0A332 (Start Timer) -0x0088E (Small Basic) -0x00BAF (Big Basic) -0x00BF3 (Square) -0x00C09 (Maze Map) -0x00CDB (Stars and Dots) -0x0051F (Symmetry) -0x00524 (Stars and Shapers) -0x00CD4 (Big Basic 2) -0x00CB9 (Choice Squares Right) -0x00CA1 (Choice Squares Middle) -0x00C80 (Choice Squares Left) -0x00C68 (Choice Squares 2 Right) -0x00C59 (Choice Squares 2 Middle) -0x00C22 (Choice Squares 2 Left) -0x034F4 (Maze Hidden 1) -0x034EC (Maze Hidden 2) -0x1C31A (Dots Pillar) -0x1C319 (Squares Pillar) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index 94e6f7a3cc97..bae1921f6095 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -1,5 +1,6 @@ +from collections import defaultdict from functools import lru_cache -from typing import Dict, List +from typing import Dict, List, Set, Tuple from .item_definition_classes import ( CATEGORY_NAME_MAPPINGS, @@ -10,11 +11,13 @@ WeightedItemDefinition, ) from .utils import ( + WitnessRule, define_new_region, get_items, get_sigma_expert_logic, get_sigma_normal_logic, get_vanilla_logic, + logical_or_witness_rules, parse_lambda, ) @@ -41,7 +44,8 @@ def read_logic_file(self, lines) -> None: current_region = new_region_and_connections[0] region_name = current_region["name"] self.ALL_REGIONS_BY_NAME[region_name] = current_region - self.STATIC_CONNECTIONS_BY_REGION_NAME[region_name] = new_region_and_connections[1] + for connection in new_region_and_connections[1]: + self.CONNECTIONS_WITH_DUPLICATES[region_name][connection[0]].add(connection[1]) current_area["regions"].append(region_name) continue @@ -80,13 +84,15 @@ def read_logic_file(self, lines) -> None: self.ENTITIES_BY_NAME[self.ENTITIES_BY_HEX[entity_hex]["checkName"]] = self.ENTITIES_BY_HEX[entity_hex] self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex] = { - "panels": parse_lambda(required_panel_lambda) + "entities": parse_lambda(required_panel_lambda) } # Lasers and Doors exist in a region, but don't have a regional *requirement* # If a laser is activated, you don't need to physically walk up to it for it to count # As such, logically, they behave more as if they were part of the "Entry" region - self.ALL_REGIONS_BY_NAME["Entry"]["panels"].append(entity_hex) + self.ALL_REGIONS_BY_NAME["Entry"]["entities"].append(entity_hex) + # However, it will also be important to keep track of their physical location for postgame purposes. + current_region["physical_entities"].append(entity_hex) continue required_item_lambda = line_split.pop(0) @@ -117,7 +123,7 @@ def read_logic_file(self, lines) -> None: required_items = frozenset(required_items) requirement = { - "panels": required_panels, + "entities": required_panels, "items": required_items } @@ -145,7 +151,37 @@ def read_logic_file(self, lines) -> None: self.ENTITIES_BY_NAME[self.ENTITIES_BY_HEX[entity_hex]["checkName"]] = self.ENTITIES_BY_HEX[entity_hex] self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex] = requirement - current_region["panels"].append(entity_hex) + current_region["entities"].append(entity_hex) + current_region["physical_entities"].append(entity_hex) + + def reverse_connection(self, source_region: str, connection: Tuple[str, Set[WitnessRule]]): + target = connection[0] + traversal_options = connection[1] + + # Reverse this connection with all its possibilities, except the ones marked as "OneWay". + for requirement in traversal_options: + remaining_options = set() + for option in requirement: + if not any(req == "TrueOneWay" for req in option): + remaining_options.add(option) + + if remaining_options: + self.CONNECTIONS_WITH_DUPLICATES[target][source_region].add(frozenset(remaining_options)) + + def reverse_connections(self): + # Iterate all connections + for region_name, connections in list(self.CONNECTIONS_WITH_DUPLICATES.items()): + for connection in connections.items(): + self.reverse_connection(region_name, connection) + + def combine_connections(self): + # All regions need to be present, and this dict is copied later - Thus, defaultdict is not the correct choice. + self.STATIC_CONNECTIONS_BY_REGION_NAME = {region_name: set() for region_name in self.ALL_REGIONS_BY_NAME} + + for source, connections in self.CONNECTIONS_WITH_DUPLICATES.items(): + for target, requirement in connections.items(): + combined_req = logical_or_witness_rules(requirement) + self.STATIC_CONNECTIONS_BY_REGION_NAME[source].add((target, combined_req)) def __init__(self, lines=None) -> None: if lines is None: @@ -154,6 +190,7 @@ def __init__(self, lines=None) -> None: # All regions with a list of panels in them and the connections to other regions, before logic adjustments self.ALL_REGIONS_BY_NAME = dict() self.ALL_AREAS_BY_NAME = dict() + self.CONNECTIONS_WITH_DUPLICATES = defaultdict(lambda: defaultdict(lambda: set())) self.STATIC_CONNECTIONS_BY_REGION_NAME = dict() self.ENTITIES_BY_HEX = dict() @@ -167,6 +204,8 @@ def __init__(self, lines=None) -> None: self.ENTITY_ID_TO_NAME = dict() self.read_logic_file(lines) + self.reverse_connections() + self.combine_connections() # Item data parsed from WitnessItems.txt diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index bb89227ca37f..5c5568b25661 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -2,7 +2,14 @@ from math import floor from pkgutil import get_data from random import random -from typing import Any, Collection, Dict, FrozenSet, List, Set, Tuple +from typing import Any, Collection, Dict, FrozenSet, Iterable, List, Set, Tuple + +# A WitnessRule is just an or-chain of and-conditions. +# It represents the set of all options that could fulfill this requirement. +# E.g. if something requires "Dots or (Shapers and Stars)", it'd be represented as: {{"Dots"}, {"Shapers, "Stars"}} +# {} is an unusable requirement. +# {{}} is an always usable requirement. +WitnessRule = FrozenSet[FrozenSet[str]] def weighted_sample(world_random: random, population: List, weights: List[float], k: int) -> List: @@ -48,7 +55,7 @@ def build_weighted_int_list(inputs: Collection[float], total: int) -> List[int]: return rounded_output -def define_new_region(region_string: str) -> Tuple[Dict[str, Any], Set[Tuple[str, FrozenSet[FrozenSet[str]]]]]: +def define_new_region(region_string: str) -> Tuple[Dict[str, Any], Set[Tuple[str, WitnessRule]]]: """ Returns a region object by parsing a line in the logic file """ @@ -76,12 +83,13 @@ def define_new_region(region_string: str) -> Tuple[Dict[str, Any], Set[Tuple[str region_obj = { "name": region_name, "shortName": region_name_simple, - "panels": list() + "entities": list(), + "physical_entities": list(), } return region_obj, options -def parse_lambda(lambda_string) -> FrozenSet[FrozenSet[str]]: +def parse_lambda(lambda_string) -> WitnessRule: """ Turns a lambda String literal like this: a | b & c into a set of sets like this: {{a}, {b, c}} @@ -181,36 +189,8 @@ def get_discard_exclusion_list() -> List[str]: return get_adjustment_file("settings/Exclusions/Discards.txt") -def get_caves_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Caves.txt") - - -def get_beyond_challenge_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Beyond_Challenge.txt") - - -def get_bottom_floor_discard_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Bottom_Floor_Discard.txt") - - -def get_bottom_floor_discard_nondoors_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Bottom_Floor_Discard_NonDoors.txt") - - -def get_mountain_upper_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Mountain_Upper.txt") - - -def get_challenge_vault_box_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Challenge_Vault_Box.txt") - - -def get_path_to_challenge_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Path_To_Challenge.txt") - - -def get_mountain_lower_exclusion_list() -> List[str]: - return get_adjustment_file("settings/Postgame/Mountain_Lower.txt") +def get_caves_except_path_to_challenge_exclusion_list() -> List[str]: + return get_adjustment_file("settings/Exclusions/Caves_Except_Path_To_Challenge.txt") def get_elevators_come_to_you() -> List[str]: @@ -233,21 +213,21 @@ def get_items() -> List[str]: return get_adjustment_file("WitnessItems.txt") -def dnf_remove_redundancies(dnf_requirement: FrozenSet[FrozenSet[str]]) -> FrozenSet[FrozenSet[str]]: +def optimize_witness_rule(witness_rule: WitnessRule) -> WitnessRule: """Removes any redundant terms from a logical formula in disjunctive normal form. This means removing any terms that are a superset of any other term get removed. This is possible because of the boolean absorption law: a | (a & b) = a""" to_remove = set() - for option1 in dnf_requirement: - for option2 in dnf_requirement: + for option1 in witness_rule: + for option2 in witness_rule: if option2 < option1: to_remove.add(option1) - return dnf_requirement - to_remove + return witness_rule - to_remove -def dnf_and(dnf_requirements: List[FrozenSet[FrozenSet[str]]]) -> FrozenSet[FrozenSet[str]]: +def logical_and_witness_rules(witness_rules: Iterable[WitnessRule]) -> WitnessRule: """ performs the "and" operator on a list of logical formula in disjunctive normal form, represented as a set of sets. A logical formula might look like this: {{a, b}, {c, d}}, which would mean "a & b | c & d". @@ -255,7 +235,7 @@ def dnf_and(dnf_requirements: List[FrozenSet[FrozenSet[str]]]) -> FrozenSet[Froz """ current_overall_requirement = frozenset({frozenset()}) - for next_dnf_requirement in dnf_requirements: + for next_dnf_requirement in witness_rules: new_requirement: Set[FrozenSet[str]] = set() for option1 in current_overall_requirement: @@ -264,4 +244,8 @@ def dnf_and(dnf_requirements: List[FrozenSet[FrozenSet[str]]]) -> FrozenSet[Froz current_overall_requirement = frozenset(new_requirement) - return dnf_remove_redundancies(current_overall_requirement) + return optimize_witness_rule(current_overall_requirement) + + +def logical_or_witness_rules(witness_rules: Iterable[WitnessRule]) -> WitnessRule: + return optimize_witness_rule(frozenset.union(*witness_rules)) diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index fa6f658b451d..535a36e13b6f 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -373,9 +373,9 @@ def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]] for area in potential_areas: regions = [ - world.player_regions.created_regions[region] + world.get_region(region) for region in static_witness_logic.ALL_AREAS_BY_NAME[area]["regions"] - if region in world.player_regions.created_regions + if region in world.player_regions.created_region_names ] locations = [location for region in regions for location in region.get_locations() if location.address] diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 01caee89515b..4335f9524f1e 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -17,13 +17,39 @@ import copy from collections import defaultdict -from functools import lru_cache from logging import warning -from typing import TYPE_CHECKING, Dict, FrozenSet, List, Set, Tuple, cast +from typing import TYPE_CHECKING, Dict, List, Set, Tuple, cast from .data import static_logic as static_witness_logic -from .data import utils from .data.item_definition_classes import DoorItemDefinition, ItemCategory, ProgressiveItemDefinition +from .data.utils import ( + WitnessRule, + define_new_region, + get_boat, + get_caves_except_path_to_challenge_exclusion_list, + get_complex_additional_panels, + get_complex_door_panels, + get_complex_doors, + get_disable_unrandomized_list, + get_discard_exclusion_list, + get_early_caves_list, + get_early_caves_start_list, + get_elevators_come_to_you, + get_ep_all_individual, + get_ep_easy, + get_ep_no_eclipse, + get_ep_obelisks, + get_laser_shuffle, + get_obelisk_keys, + get_simple_additional_panels, + get_simple_doors, + get_simple_panels, + get_symbol_shuffle_list, + get_vault_exclusion_list, + logical_and_witness_rules, + logical_or_witness_rules, + parse_lambda, +) if TYPE_CHECKING: from . import WitnessWorld @@ -32,8 +58,7 @@ class WitnessPlayerLogic: """WITNESS LOGIC CLASS""" - @lru_cache(maxsize=None) - def reduce_req_within_region(self, entity_hex: str) -> FrozenSet[FrozenSet[str]]: + def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: """ Panels in this game often only turn on when other panels are solved. Those other panels may have different item requirements. @@ -42,35 +67,39 @@ def reduce_req_within_region(self, entity_hex: str) -> FrozenSet[FrozenSet[str]] Panels outside of the same region will still be checked manually. """ - if entity_hex in self.COMPLETELY_DISABLED_ENTITIES or entity_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES: + if self.is_disabled(entity_hex): return frozenset() entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex] - these_items = frozenset({frozenset()}) + if entity_obj["region"] is not None and entity_obj["region"]["name"] in self.UNREACHABLE_REGIONS: + return frozenset() - if entity_obj["id"]: - these_items = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex]["items"] + # For the requirement of an entity, we consider two things: + # 1. Any items this entity needs (e.g. Symbols or Door Items) + these_items = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex].get("items", frozenset({frozenset()})) + # 2. Any entities that this entity depends on (e.g. one panel powering on the next panel in a set) + these_panels = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex]["entities"] + # Remove any items that don't actually exist in the settings (e.g. Symbol Shuffle turned off) these_items = frozenset({ subset.intersection(self.THEORETICAL_ITEMS_NO_MULTI) for subset in these_items }) + # Update the list of "items that are actually being used by any entity" for subset in these_items: self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI.update(subset) - these_panels = self.DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex]["panels"] - + # If this entity is opened by a door item that exists in the itempool, add that item to its requirements. + # Also, remove any original power requirements this entity might have had. if entity_hex in self.DOOR_ITEMS_BY_ID: door_items = frozenset({frozenset([item]) for item in self.DOOR_ITEMS_BY_ID[entity_hex]}) - all_options: Set[FrozenSet[str]] = set() - for dependent_item in door_items: self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI.update(dependent_item) - for items_option in these_items: - all_options.add(items_option.union(dependent_item)) + + all_options = logical_and_witness_rules([door_items, these_items]) # If this entity is not an EP, and it has an associated door item, ignore the original power dependencies if static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] != "EP": @@ -90,46 +119,70 @@ def reduce_req_within_region(self, entity_hex: str) -> FrozenSet[FrozenSet[str]] else: these_items = all_options - disabled_eps = {eHex for eHex in self.COMPLETELY_DISABLED_ENTITIES - if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[eHex]["entityType"] == "EP"} - - these_panels = frozenset({panels - disabled_eps - for panels in these_panels}) - - if these_panels == frozenset({frozenset()}): - return these_items + # Now that we have item requirements and entity dependencies, it's time for the dependency reduction. - all_options = set() + # For each entity that this entity depends on (e.g. a panel turning on another panel), + # Add that entities requirements to this entity. + # If there are multiple options, consider each, and then or-chain them. + all_options = list() for option in these_panels: dependent_items_for_option = frozenset({frozenset()}) + # For each entity in this option, resolve it to its actual requirement. for option_entity in option: dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity) - if option_entity in self.ALWAYS_EVENT_NAMES_BY_HEX: - new_items = frozenset({frozenset([option_entity])}) - elif (entity_hex, option_entity) in self.CONDITIONAL_EVENTS: - new_items = frozenset({frozenset([option_entity])}) - self.USED_EVENT_NAMES_BY_HEX[option_entity] = self.CONDITIONAL_EVENTS[(entity_hex, option_entity)] - elif option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect", - "PP2 Weirdness", "Theater to Tunnels"}: + if option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect", + "PP2 Weirdness", "Theater to Tunnels"}: new_items = frozenset({frozenset([option_entity])}) + elif option_entity in self.DISABLE_EVERYTHING_BEHIND: + new_items = frozenset() else: - new_items = self.reduce_req_within_region(option_entity) - if dep_obj["region"] and entity_obj["region"] != dep_obj["region"]: - new_items = frozenset( - frozenset(possibility | {dep_obj["region"]["name"]}) - for possibility in new_items - ) + theoretical_new_items = self.get_entity_requirement(option_entity) + + if not theoretical_new_items: + # If the dependent entity is unsolvable & it is an EP, the current entity is an Obelisk Side. + # In this case, we actually have to skip it because it will just become pre-solved instead. + if dep_obj["entityType"] == "EP": + continue + # If the dependent entity is unsolvable and is NOT an EP, this requirement option is invalid. + new_items = frozenset() + elif option_entity in self.ALWAYS_EVENT_NAMES_BY_HEX: + new_items = frozenset({frozenset([option_entity])}) + elif (entity_hex, option_entity) in self.CONDITIONAL_EVENTS: + new_items = frozenset({frozenset([option_entity])}) + self.USED_EVENT_NAMES_BY_HEX[option_entity] = self.CONDITIONAL_EVENTS[ + (entity_hex, option_entity) + ] + else: + new_items = theoretical_new_items + if dep_obj["region"] and entity_obj["region"] != dep_obj["region"]: + new_items = frozenset( + frozenset(possibility | {dep_obj["region"]["name"]}) + for possibility in new_items + ) + + dependent_items_for_option = logical_and_witness_rules([dependent_items_for_option, new_items]) - dependent_items_for_option = utils.dnf_and([dependent_items_for_option, new_items]) + # Combine the resolved dependent entity requirements with the item requirements of this entity. + all_options.append(logical_and_witness_rules([these_items, dependent_items_for_option])) - for items_option in these_items: - for dependent_item in dependent_items_for_option: - all_options.add(items_option.union(dependent_item)) + # or-chain all separate dependent entity options. + return logical_or_witness_rules(all_options) - return utils.dnf_remove_redundancies(frozenset(all_options)) + def get_entity_requirement(self, entity_hex: str) -> WitnessRule: + """ + Get requirement of entity by its hex code. + These requirements are cached, with the actual function calculating them being reduce_req_within_region. + """ + requirement = self.REQUIREMENTS_BY_HEX.get(entity_hex) + + if requirement is None: + requirement = self.reduce_req_within_region(entity_hex) + self.REQUIREMENTS_BY_HEX[entity_hex] = requirement + + return requirement def make_single_adjustment(self, adj_type: str, line: str) -> None: from .data import static_items as static_witness_items @@ -191,11 +244,11 @@ def make_single_adjustment(self, adj_type: str, line: str) -> None: line_split = line.split(" - ") requirement = { - "panels": utils.parse_lambda(line_split[1]), + "entities": parse_lambda(line_split[1]), } if len(line_split) > 2: - required_items = utils.parse_lambda(line_split[2]) + required_items = parse_lambda(line_split[2]) items_actually_in_the_game = [ item_name for item_name, item_definition in static_witness_logic.ALL_ITEMS.items() if item_definition.category is ItemCategory.SYMBOL @@ -226,9 +279,9 @@ def make_single_adjustment(self, adj_type: str, line: str) -> None: return if adj_type == "Region Changes": - new_region_and_options = utils.define_new_region(line + ":") + new_region_and_options = define_new_region(line + ":") - self.CONNECTIONS_BY_REGION_NAME[new_region_and_options[0]["name"]] = new_region_and_options[1] + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[new_region_and_options[0]["name"]] = new_region_and_options[1] return @@ -238,102 +291,99 @@ def make_single_adjustment(self, adj_type: str, line: str) -> None: target_region = line_split[1] panel_set_string = line_split[2] - for connection in self.CONNECTIONS_BY_REGION_NAME[source_region]: + for connection in self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region]: if connection[0] == target_region: - self.CONNECTIONS_BY_REGION_NAME[source_region].remove(connection) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].remove(connection) if panel_set_string == "TrueOneWay": - self.CONNECTIONS_BY_REGION_NAME[source_region].add( + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].add( (target_region, frozenset({frozenset(["TrueOneWay"])})) ) else: - new_lambda = connection[1] | utils.parse_lambda(panel_set_string) - self.CONNECTIONS_BY_REGION_NAME[source_region].add((target_region, new_lambda)) + new_lambda = logical_or_witness_rules([connection[1], parse_lambda(panel_set_string)]) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].add((target_region, new_lambda)) break - else: # Execute if loop did not break. TIL this is a thing you can do! - new_conn = (target_region, utils.parse_lambda(panel_set_string)) - self.CONNECTIONS_BY_REGION_NAME[source_region].add(new_conn) + else: + new_conn = (target_region, parse_lambda(panel_set_string)) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].add(new_conn) if adj_type == "Added Locations": if "0x" in line: line = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[line]["checkName"] self.ADDED_CHECKS.add(line) - @staticmethod - def handle_postgame(world: "WitnessWorld") -> List[List[str]]: - # In shuffle_postgame, panels that become accessible "after or at the same time as the goal" are disabled. - # This has a lot of complicated considerations, which I'll try my best to explain. + def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: + """ + In shuffle_postgame, panels that become accessible "after or at the same time as the goal" are disabled. + This mostly involves the disabling of key panels (e.g. long box when the goal is short box). + These will then hava a cascading effect on other entities that are locked "behind" them. + """ + postgame_adjustments = [] # Make some quick references to some options - doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region accessibility implications. + remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no region accessibility implications. early_caves = world.options.early_caves victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers chal_lasers = world.options.challenge_lasers - # Goal is "short box" but short box requires more lasers than long box - reverse_shortbox_goal = victory == "mountain_box_short" and mnt_lasers > chal_lasers - # Goal is "short box", and long box requires at least as many lasers as short box (as god intended) proper_shortbox_goal = victory == "mountain_box_short" and chal_lasers >= mnt_lasers # Goal is "long box", but short box requires at least as many lasers than long box. reverse_longbox_goal = victory == "mountain_box_long" and mnt_lasers >= chal_lasers - # If goal is shortbox or "reverse longbox", you will never enter the mountain from the top before winning. - mountain_enterable_from_top = not (victory == "mountain_box_short" or reverse_longbox_goal) + # ||| Section 1: Proper postgame cases ||| + # When something only comes into logic after the goal, e.g. "longbox is postgame if the goal is shortbox". - # Caves & Challenge should never have anything if doors are vanilla - definitionally "post-game" - # This is technically imprecise, but it matches player expectations better. - if not (early_caves or doors): - postgame_adjustments.append(utils.get_caves_exclusion_list()) - postgame_adjustments.append(utils.get_beyond_challenge_exclusion_list()) + # Disable anything directly locked by the victory panel + self.DISABLE_EVERYTHING_BEHIND.add(self.VICTORY_LOCATION) - # If Challenge is the goal, some panels on the way need to be left on, as well as Challenge Vault box itself - if not victory == "challenge": - postgame_adjustments.append(utils.get_path_to_challenge_exclusion_list()) - postgame_adjustments.append(utils.get_challenge_vault_box_exclusion_list()) - - # Challenge can only have something if the goal is not challenge or longbox itself. - # In case of shortbox, it'd have to be a "reverse shortbox" situation where shortbox requires *more* lasers. - # In that case, it'd also have to be a doors mode, but that's already covered by the previous block. - if not (victory == "elevator" or reverse_shortbox_goal): - postgame_adjustments.append(utils.get_beyond_challenge_exclusion_list()) - if not victory == "challenge": - postgame_adjustments.append(utils.get_challenge_vault_box_exclusion_list()) - - # Mountain can't be reached if the goal is shortbox (or "reverse long box") - if not mountain_enterable_from_top: - postgame_adjustments.append(utils.get_mountain_upper_exclusion_list()) - - # Same goes for lower mountain, but that one *can* be reached in remote doors modes. - if not doors: - postgame_adjustments.append(utils.get_mountain_lower_exclusion_list()) - - # The Mountain Bottom Floor Discard is a bit complicated, so we handle it separately. ("it" == the Discard) - # In Elevator Goal, it is definitionally in the post-game, unless remote doors is played. - # In Challenge Goal, it is before the Challenge, so it is not post-game. - # In Short Box Goal, you can win before turning it on, UNLESS Short Box requires MORE lasers than long box. - # In Long Box Goal, it is always in the post-game because solving long box is what turns it on. - if not ((victory == "elevator" and doors) or victory == "challenge" or (reverse_shortbox_goal and doors)): - # We now know Bottom Floor Discard is in the post-game. - # This has different consequences depending on whether remote doors is being played. - # If doors are vanilla, Bottom Floor Discard locks a door to an area, which has to be disabled as well. - if doors: - postgame_adjustments.append(utils.get_bottom_floor_discard_exclusion_list()) - else: - postgame_adjustments.append(utils.get_bottom_floor_discard_nondoors_exclusion_list()) - - # In Challenge goal + early_caves + vanilla doors, you could find something important on Bottom Floor Discard, - # including the Caves Shortcuts themselves if playing "early_caves: start_inventory". - # This is another thing that was deemed "unfun" more than fitting the actual definition of post-game. - if victory == "challenge" and early_caves and not doors: - postgame_adjustments.append(utils.get_bottom_floor_discard_nondoors_exclusion_list()) + # If we have a long box goal, Challenge is behind the amount of lasers required to just win. + # This is technically slightly incorrect as the Challenge Vault Box could contain a *symbol* that is required + # to open Mountain Entry (Stars 2). However, since there is a very easy sphere 1 snipe, this is not considered. + if victory == "mountain_box_long": + postgame_adjustments.append(["Disabled Locations:", "0x0A332 (Challenge Timer Start)"]) - # If we have a proper short box goal, long box will never be activated first. + # If we have a proper short box goal, anything based on challenge lasers will never have something required. if proper_shortbox_goal: postgame_adjustments.append(["Disabled Locations:", "0xFFF00 (Mountain Box Long)"]) + postgame_adjustments.append(["Disabled Locations:", "0x0A332 (Challenge Timer Start)"]) + + # In a case where long box can be activated before short box, short box is postgame. + if reverse_longbox_goal: + postgame_adjustments.append(["Disabled Locations:", "0x09F7F (Mountain Box Short)"]) + + # ||| Section 2: "Fun" considerations ||| + # These are cases in which it was deemed "unfun" to have an "oops, all lasers" situation, especially when + # it's for a single possible item. + + mbfd_extra_exclusions = ( + # Progressive Dots 2 behind 11 lasers in an Elevator seed with vanilla doors = :( + victory == "elevator" and not remote_doors + + # Caves Shortcuts / Challenge Entry (Panel) on MBFD in a Challenge seed with vanilla doors = :( + or victory == "challenge" and early_caves and not remote_doors + ) + + if mbfd_extra_exclusions: + postgame_adjustments.append(["Disabled Locations:", "0xFFF00 (Mountain Box Long)"]) + + # Another big postgame case that is missed is "Desert Laser Redirect (Panel)". + # An 11 lasers longbox seed could technically have this item on Challenge Vault Box. + # This case is not considered and we will act like Desert Laser Redirect (Panel) is always accessible. + # (Which means we do no additional work, this comment just exists to document that case) + + # ||| Section 3: "Post-or-equal-game" cases ||| + # These are cases in which something comes into logic *at the same time* as your goal and thus also can't + # possibly have a required item. These can be a bit awkward. + + # When your victory is Challenge, but you have to get to it the vanilla way, there are no required items + # that can show up in the Caves that aren't also needed on the descent through Mountain. + # So, we should disable all entities in the Caves and Tunnels *except* for those that are required to enter. + if not (early_caves or remote_doors) and victory == "challenge": + postgame_adjustments.append(get_caves_except_path_to_challenge_exclusion_list()) return postgame_adjustments @@ -343,7 +393,7 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: # Make condensed references to some options - doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region accessibility implications. + remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region access implications. lasers = world.options.shuffle_lasers victory = world.options.victory_condition mnt_lasers = world.options.mountain_lasers @@ -357,16 +407,16 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if not world.options.shuffle_discarded_panels: # In disable_non_randomized, the discards are needed for alternate activation triggers, UNLESS both # (remote) doors and lasers are shuffled. - if not world.options.disable_non_randomized_puzzles or (doors and lasers): - adjustment_linesets_in_order.append(utils.get_discard_exclusion_list()) + if not world.options.disable_non_randomized_puzzles or (remote_doors and lasers): + adjustment_linesets_in_order.append(get_discard_exclusion_list()) - if doors: - adjustment_linesets_in_order.append(utils.get_bottom_floor_discard_exclusion_list()) + if remote_doors: + adjustment_linesets_in_order.append(["Disabled Locations:", "0x17FA2"]) if not world.options.shuffle_vault_boxes: - adjustment_linesets_in_order.append(utils.get_vault_exclusion_list()) + adjustment_linesets_in_order.append(get_vault_exclusion_list()) if not victory == "challenge": - adjustment_linesets_in_order.append(utils.get_challenge_vault_box_exclusion_list()) + adjustment_linesets_in_order.append(["Disabled Locations:", "0x0A332"]) # Victory Condition @@ -389,54 +439,54 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: ]) if world.options.disable_non_randomized_puzzles: - adjustment_linesets_in_order.append(utils.get_disable_unrandomized_list()) + adjustment_linesets_in_order.append(get_disable_unrandomized_list()) if world.options.shuffle_symbols: - adjustment_linesets_in_order.append(utils.get_symbol_shuffle_list()) + adjustment_linesets_in_order.append(get_symbol_shuffle_list()) if world.options.EP_difficulty == "normal": - adjustment_linesets_in_order.append(utils.get_ep_easy()) + adjustment_linesets_in_order.append(get_ep_easy()) elif world.options.EP_difficulty == "tedious": - adjustment_linesets_in_order.append(utils.get_ep_no_eclipse()) + adjustment_linesets_in_order.append(get_ep_no_eclipse()) if world.options.door_groupings == "regional": if world.options.shuffle_doors == "panels": - adjustment_linesets_in_order.append(utils.get_simple_panels()) + adjustment_linesets_in_order.append(get_simple_panels()) elif world.options.shuffle_doors == "doors": - adjustment_linesets_in_order.append(utils.get_simple_doors()) + adjustment_linesets_in_order.append(get_simple_doors()) elif world.options.shuffle_doors == "mixed": - adjustment_linesets_in_order.append(utils.get_simple_doors()) - adjustment_linesets_in_order.append(utils.get_simple_additional_panels()) + adjustment_linesets_in_order.append(get_simple_doors()) + adjustment_linesets_in_order.append(get_simple_additional_panels()) else: if world.options.shuffle_doors == "panels": - adjustment_linesets_in_order.append(utils.get_complex_door_panels()) - adjustment_linesets_in_order.append(utils.get_complex_additional_panels()) + adjustment_linesets_in_order.append(get_complex_door_panels()) + adjustment_linesets_in_order.append(get_complex_additional_panels()) elif world.options.shuffle_doors == "doors": - adjustment_linesets_in_order.append(utils.get_complex_doors()) + adjustment_linesets_in_order.append(get_complex_doors()) elif world.options.shuffle_doors == "mixed": - adjustment_linesets_in_order.append(utils.get_complex_doors()) - adjustment_linesets_in_order.append(utils.get_complex_additional_panels()) + adjustment_linesets_in_order.append(get_complex_doors()) + adjustment_linesets_in_order.append(get_complex_additional_panels()) if world.options.shuffle_boat: - adjustment_linesets_in_order.append(utils.get_boat()) + adjustment_linesets_in_order.append(get_boat()) if world.options.early_caves == "starting_inventory": - adjustment_linesets_in_order.append(utils.get_early_caves_start_list()) + adjustment_linesets_in_order.append(get_early_caves_start_list()) - if world.options.early_caves == "add_to_pool" and not doors: - adjustment_linesets_in_order.append(utils.get_early_caves_list()) + if world.options.early_caves == "add_to_pool" and not remote_doors: + adjustment_linesets_in_order.append(get_early_caves_list()) if world.options.elevators_come_to_you: - adjustment_linesets_in_order.append(utils.get_elevators_come_to_you()) + adjustment_linesets_in_order.append(get_elevators_come_to_you()) for item in self.YAML_ADDED_ITEMS: adjustment_linesets_in_order.append(["Items:", item]) if lasers: - adjustment_linesets_in_order.append(utils.get_laser_shuffle()) + adjustment_linesets_in_order.append(get_laser_shuffle()) if world.options.shuffle_EPs and world.options.obelisk_keys: - adjustment_linesets_in_order.append(utils.get_obelisk_keys()) + adjustment_linesets_in_order.append(get_obelisk_keys()) if world.options.shuffle_EPs == "obelisk_sides": ep_gen = ((ep_hex, ep_obj) for (ep_hex, ep_obj) in self.REFERENCE_LOGIC.ENTITIES_BY_HEX.items() @@ -448,10 +498,10 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: ep_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[ep_hex]["checkName"] self.ALWAYS_EVENT_NAMES_BY_HEX[ep_hex] = f"{obelisk_name} - {ep_name}" else: - adjustment_linesets_in_order.append(["Disabled Locations:"] + utils.get_ep_obelisks()[1:]) + adjustment_linesets_in_order.append(["Disabled Locations:"] + get_ep_obelisks()[1:]) if not world.options.shuffle_EPs: - adjustment_linesets_in_order.append(["Disabled Locations:"] + utils.get_ep_all_individual()[1:]) + adjustment_linesets_in_order.append(["Disabled Locations:"] + get_ep_all_individual()[1:]) for yaml_disabled_location in self.YAML_DISABLED_LOCATIONS: if yaml_disabled_location not in self.REFERENCE_LOGIC.ENTITIES_BY_NAME: @@ -482,16 +532,189 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if entity_id in self.DOOR_ITEMS_BY_ID: del self.DOOR_ITEMS_BY_ID[entity_id] - def make_dependency_reduced_checklist(self) -> None: + def discover_reachable_regions(self): + """ + Some options disable panels or remove specific items. + This can make entire regions completely unreachable, because all their incoming connections are invalid. + This function starts from the Entry region and performs a graph search to discover all reachable regions. + """ + reachable_regions = {"Entry"} + new_regions_found = True + + # This for loop "floods" the region graph until no more new regions are discovered. + # Note that connections that rely on disabled entities are considered invalid. + # This fact may lead to unreachable regions being discovered. + while new_regions_found: + new_regions_found = False + regions_to_check = reachable_regions.copy() + + # Find new regions through connections from currently reachable regions + while regions_to_check: + next_region = regions_to_check.pop() + + for region_exit in self.CONNECTIONS_BY_REGION_NAME[next_region]: + target = region_exit[0] + + if target in reachable_regions: + continue + + # There may be multiple conncetions between two regions. We should check all of them to see if + # any of them are valid. + for option in region_exit[1]: + # If a connection requires having access to a not-yet-reached region, do not consider it. + # Otherwise, this connection is valid, and the target region is reachable -> break for loop + if not any(req in self.CONNECTIONS_BY_REGION_NAME and req not in reachable_regions + for req in option): + break + # If none of the connections were valid, this region is not reachable this way, for now. + else: + continue + + new_regions_found = True + regions_to_check.add(target) + reachable_regions.add(target) + + return reachable_regions + + def find_unsolvable_entities(self, world: "WitnessWorld") -> None: + """ + Settings like "shuffle_postgame: False" may disable certain panels. + This may make panels or regions logically locked by those panels unreachable. + We will determine these automatically and disable them as well. + """ + + all_regions = set(self.CONNECTIONS_BY_REGION_NAME_THEORETICAL) + + while True: + # Re-make the dependency reduced entity requirements dict, which depends on currently + self.make_dependency_reduced_checklist() + + # Check if any regions have become unreachable. + reachable_regions = self.discover_reachable_regions() + new_unreachable_regions = all_regions - reachable_regions - self.UNREACHABLE_REGIONS + if new_unreachable_regions: + self.UNREACHABLE_REGIONS.update(new_unreachable_regions) + + # Then, discover unreachable entities. + newly_discovered_disabled_entities = set() + + # First, entities in unreachable regions are obviously themselves unreachable. + for region in new_unreachable_regions: + for entity in static_witness_logic.ALL_REGIONS_BY_NAME[region]["physical_entities"]: + # Never disable the Victory Location. + if entity == self.VICTORY_LOCATION: + continue + + # Never disable a laser (They should still function even if you can't walk up to them). + if static_witness_logic.ENTITIES_BY_HEX[entity]["entityType"] == "Laser": + continue + + newly_discovered_disabled_entities.add(entity) + + # Secondly, any entities that depend on disabled entities are unreachable as well. + for entity, req in self.REQUIREMENTS_BY_HEX.items(): + # If the requirement is empty (unsolvable) and it isn't disabled already, add it to "newly disabled" + if not req and not self.is_disabled(entity): + # Never disable the Victory Location. + if entity == self.VICTORY_LOCATION: + continue + + # If we are disabling a laser, something has gone wrong. + if static_witness_logic.ENTITIES_BY_HEX[entity]["entityType"] == "Laser": + laser_name = static_witness_logic.ENTITIES_BY_HEX[entity]["checkName"] + player_name = world.multiworld.get_player_name(world.player) + raise RuntimeError(f"Somehow, {laser_name} was disabled for player {player_name}." + f" This is not allowed to happen, please report to Violet.") + + newly_discovered_disabled_entities.add(entity) + + # Disable the newly determined unreachable entities. + self.COMPLETELY_DISABLED_ENTITIES.update(newly_discovered_disabled_entities) + + # If we didn't find any new unreachable regions or entities this cycle, we are done. + # If we did, we need to do another cycle to see if even more regions or entities became unreachable. + if not new_unreachable_regions and not newly_discovered_disabled_entities: + return + + def reduce_connection_requirement(self, connection: Tuple[str, WitnessRule]) -> WitnessRule: + all_possibilities = [] + + # Check each traversal option individually + for option in connection[1]: + individual_entity_requirements = [] + for entity in option: + # If a connection requires solving a disabled entity, it is not valid. + if not self.solvability_guaranteed(entity) or entity in self.DISABLE_EVERYTHING_BEHIND: + individual_entity_requirements.append(frozenset()) + # If a connection requires acquiring an event, add that event to its requirements. + elif (entity in self.ALWAYS_EVENT_NAMES_BY_HEX + or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX): + individual_entity_requirements.append(frozenset({frozenset({entity})})) + # If a connection requires entities, use their newly calculated independent requirements. + else: + entity_req = self.get_entity_requirement(entity) + + if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"]: + region_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"]["name"] + entity_req = logical_and_witness_rules([entity_req, frozenset({frozenset({region_name})})]) + + individual_entity_requirements.append(entity_req) + + # Merge all possible requirements into one DNF condition. + all_possibilities.append(logical_and_witness_rules(individual_entity_requirements)) + + return logical_or_witness_rules(all_possibilities) + + def make_dependency_reduced_checklist(self): """ - Turns dependent check set into semi-independent check set + Every entity has a requirement. This requirement may involve other entities. + Example: Solving a panel powers a cable, and that cable turns on the next panel. + These dependencies are specified in the logic files (e.g. "WitnessLogic.txt") and may be modified by options. + + Recursively having to check the requirements of every dependent entity would be very slow, so we go through this + recursion once and make a single, independent requirement for each entity. + + This requirement may include symbol items, door items, regions, or events. + A requirement is saved as a two-dimensional set that represents a disjuntive normal form. """ + # Requirements are cached per entity. However, we might redo the whole reduction process multiple times. + # So, we first clear this cache. + self.REQUIREMENTS_BY_HEX = dict() + + # We also clear any data structures that we might have filled in a previous dependency reduction + self.REQUIREMENTS_BY_HEX = dict() + self.USED_EVENT_NAMES_BY_HEX = dict() + self.CONNECTIONS_BY_REGION_NAME = dict() + self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set() + + # Make independent requirements for entities for entity_hex in self.DEPENDENT_REQUIREMENTS_BY_HEX.keys(): - indep_requirement = self.reduce_req_within_region(entity_hex) + indep_requirement = self.get_entity_requirement(entity_hex) self.REQUIREMENTS_BY_HEX[entity_hex] = indep_requirement + # Make independent region connection requirements based on the entities they require + for region, connections in self.CONNECTIONS_BY_REGION_NAME_THEORETICAL.items(): + self.CONNECTIONS_BY_REGION_NAME[region] = [] + + new_connections = [] + + for connection in connections: + overall_requirement = self.reduce_connection_requirement(connection) + + # If there is a way to use this connection, add it. + if overall_requirement: + new_connections.append((connection[0], overall_requirement)) + + # If there are any usable outgoing connections from this region, add them. + if new_connections: + self.CONNECTIONS_BY_REGION_NAME[region] = new_connections + + def finalize_items(self): + """ + Finalise which items are used in the world, and handle their progressive versions. + """ for item in self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI: if item not in self.THEORETICAL_ITEMS: progressive_item_name = static_witness_logic.get_parent_progressive_item(item) @@ -505,33 +728,6 @@ def make_dependency_reduced_checklist(self) -> None: else: self.PROG_ITEMS_ACTUALLY_IN_THE_GAME.add(item) - for region, connections in self.CONNECTIONS_BY_REGION_NAME.items(): - new_connections = [] - - for connection in connections: - overall_requirement = frozenset() - - for option in connection[1]: - individual_entity_requirements = [] - for entity in option: - if (entity in self.ALWAYS_EVENT_NAMES_BY_HEX - or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX): - individual_entity_requirements.append(frozenset({frozenset({entity})})) - else: - entity_req = self.reduce_req_within_region(entity) - - if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"]: - region_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"]["name"] - entity_req = utils.dnf_and([entity_req, frozenset({frozenset({region_name})})]) - - individual_entity_requirements.append(entity_req) - - overall_requirement |= utils.dnf_and(individual_entity_requirements) - - new_connections.append((connection[0], overall_requirement)) - - self.CONNECTIONS_BY_REGION_NAME[region] = new_connections - def solvability_guaranteed(self, entity_hex: str) -> bool: return not ( entity_hex in self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY @@ -539,6 +735,12 @@ def solvability_guaranteed(self, entity_hex: str) -> bool: or entity_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES ) + def is_disabled(self, entity_hex: str) -> bool: + return ( + entity_hex in self.COMPLETELY_DISABLED_ENTITIES + or entity_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES + ) + def determine_unrequired_entities(self, world: "WitnessWorld") -> None: """Figure out which major items are actually useless in this world's settings""" @@ -588,7 +790,6 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: "0x01BEA": difficulty == "none" and eps_shuffled, # Keep PP2 "0x0A0C9": eps_shuffled or discards_shuffled or disable_non_randomized, # Cargo Box Entry Door "0x09EEB": discards_shuffled or mountain_upper_included, # Mountain Floor 2 Elevator Control Panel - "0x09EDD": mountain_upper_included, # Mountain Floor 2 Exit Door "0x17CAB": symbols_shuffled or not disable_non_randomized or "0x17CAB" not in self.DOOR_ITEMS_BY_ID, # Jungle Popup Wall Panel } @@ -598,20 +799,24 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: item_name for item_name, is_required in is_item_required_dict.items() if not is_required } - def make_event_item_pair(self, panel: str) -> Tuple[str, str]: + def make_event_item_pair(self, entity_hex: str) -> Tuple[str, str]: """ Makes a pair of an event panel and its event item """ - action = " Opened" if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel]["entityType"] == "Door" else " Solved" + action = " Opened" if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Door" else " Solved" - name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel]["checkName"] + action - if panel not in self.USED_EVENT_NAMES_BY_HEX: - warning(f'Panel "{name}" does not have an associated event name.') - self.USED_EVENT_NAMES_BY_HEX[panel] = name + " Event" - pair = (name, self.USED_EVENT_NAMES_BY_HEX[panel]) + name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["checkName"] + action + if entity_hex not in self.USED_EVENT_NAMES_BY_HEX: + warning(f'Entity "{name}" does not have an associated event name.') + self.USED_EVENT_NAMES_BY_HEX[entity_hex] = name + " Event" + pair = (name, self.USED_EVENT_NAMES_BY_HEX[entity_hex]) return pair def make_event_panel_lists(self) -> None: + """ + Makes event-item pairs for entities with associated events, unless these entities are disabled. + """ + self.ALWAYS_EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory" self.USED_EVENT_NAMES_BY_HEX.update(self.ALWAYS_EVENT_NAMES_BY_HEX) @@ -636,6 +841,8 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY = set() + self.UNREACHABLE_REGIONS = set() + self.THEORETICAL_ITEMS = set() self.THEORETICAL_ITEMS_NO_MULTI = set() self.MULTI_AMOUNTS = defaultdict(lambda: 1) @@ -654,14 +861,16 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in elif self.DIFFICULTY == "none": self.REFERENCE_LOGIC = static_witness_logic.vanilla - self.CONNECTIONS_BY_REGION_NAME = copy.deepcopy(self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL = copy.deepcopy( + self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME + ) + self.CONNECTIONS_BY_REGION_NAME = dict() self.DEPENDENT_REQUIREMENTS_BY_HEX = copy.deepcopy(self.REFERENCE_LOGIC.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX) self.REQUIREMENTS_BY_HEX = dict() - # Determining which panels need to be events is a difficult process. - # At the end, we will have EVENT_ITEM_PAIRS for all the necessary ones. self.EVENT_ITEM_PAIRS = dict() self.COMPLETELY_DISABLED_ENTITIES = set() + self.DISABLE_EVERYTHING_BEHIND = set() self.PRECOMPLETED_LOCATIONS = set() self.EXCLUDED_LOCATIONS = set() self.ADDED_CHECKS = set() @@ -687,7 +896,18 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.USED_EVENT_NAMES_BY_HEX = {} self.CONDITIONAL_EVENTS = {} + # The basic requirements to solve each entity come from StaticWitnessLogic. + # However, for any given world, the options (e.g. which item shuffles are enabled) affect the requirements. self.make_options_adjustments(world) self.determine_unrequired_entities(world) + self.find_unsolvable_entities(world) + + # After we have adjusted the raw requirements, we perform a dependency reduction for the entity requirements. + # This will make the access conditions way faster, instead of recursively checking dependent entities each time. self.make_dependency_reduced_checklist() + + # Finalize which items actually exist in the MultiWorld and which get grouped into progressive items. + self.finalize_items() + + # Create event-item pairs for specific panels in the game. self.make_event_panel_lists() diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index e1f0ddb2161f..35f4e9544212 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -3,13 +3,14 @@ and connects them with the proper requirements """ from collections import defaultdict -from typing import TYPE_CHECKING, Dict, FrozenSet, List, Tuple +from typing import TYPE_CHECKING, Dict, List, Set, Tuple from BaseClasses import Entrance, Region from worlds.generic.Rules import CollectionRule from .data import static_logic as static_witness_logic +from .data.utils import WitnessRule, optimize_witness_rule from .locations import WitnessPlayerLocations, static_witness_locations from .player_logic import WitnessPlayerLogic @@ -24,7 +25,7 @@ class WitnessPlayerRegions: logic = None @staticmethod - def make_lambda(item_requirement: FrozenSet[FrozenSet[str]], world: "WitnessWorld") -> CollectionRule: + def make_lambda(item_requirement: WitnessRule, world: "WitnessWorld") -> CollectionRule: from .rules import _meets_item_requirements """ @@ -34,8 +35,8 @@ def make_lambda(item_requirement: FrozenSet[FrozenSet[str]], world: "WitnessWorl return _meets_item_requirements(item_requirement, world) - def connect_if_possible(self, world: "WitnessWorld", source: str, target: str, req: FrozenSet[FrozenSet[str]], - regions_by_name: Dict[str, Region], backwards: bool = False): + def connect_if_possible(self, world: "WitnessWorld", source: str, target: str, req: WitnessRule, + regions_by_name: Dict[str, Region]): """ connect two regions and set the corresponding requirement """ @@ -43,10 +44,6 @@ def connect_if_possible(self, world: "WitnessWorld", source: str, target: str, r # Remove any possibilities where being in the target region would be required anyway. real_requirement = frozenset({option for option in req if target not in option}) - # There are some connections that should only be done one way. If this is a backwards connection, check for that - if backwards: - real_requirement = frozenset({option for option in real_requirement if "TrueOneWay" not in option}) - # Dissolve any "True" or "TrueOneWay" real_requirement = frozenset({option - {"True", "TrueOneWay"} for option in real_requirement}) @@ -56,12 +53,12 @@ def connect_if_possible(self, world: "WitnessWorld", source: str, target: str, r # We don't need to check for the accessibility of the source region. final_requirement = frozenset({option - frozenset({source}) for option in real_requirement}) + final_requirement = optimize_witness_rule(final_requirement) source_region = regions_by_name[source] target_region = regions_by_name[target] - backwards = " Backwards" if backwards else "" - connection_name = source + " to " + target + backwards + connection_name = source + " to " + target connection = Entrance( world.player, @@ -74,7 +71,8 @@ def connect_if_possible(self, world: "WitnessWorld", source: str, target: str, r source_region.exits.append(connection) connection.connect(target_region) - self.created_entrances[source, target].append(connection) + self.two_way_entrance_register[source, target].append(connection) + self.two_way_entrance_register[target, source].append(connection) # Register any necessary indirect connections mentioned_regions = { @@ -94,14 +92,19 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic all_locations = set() regions_by_name = dict() - for region_name, region in self.reference_logic.ALL_REGIONS_BY_NAME.items(): + regions_to_create = { + k: v for k, v in self.reference_logic.ALL_REGIONS_BY_NAME.items() + if k not in player_logic.UNREACHABLE_REGIONS + } + + 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["panels"] + 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 ] locations_for_this_region += [ - static_witness_locations.get_event_name(panel) for panel in region["panels"] + static_witness_locations.get_event_name(panel) for panel in region["entities"] if static_witness_locations.get_event_name(panel) in self.player_locations.EVENT_LOCATION_TABLE ] @@ -111,31 +114,13 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic regions_by_name[region_name] = new_region - for region_name, region in self.reference_logic.ALL_REGIONS_BY_NAME.items(): - for connection in player_logic.CONNECTIONS_BY_REGION_NAME[region_name]: - self.connect_if_possible(world, region_name, connection[0], connection[1], regions_by_name) - self.connect_if_possible(world, connection[0], region_name, connection[1], regions_by_name, True) - - # find regions that are completely disconnected from the start node and remove them - regions_to_check = {"Menu"} - reachable_regions = {"Menu"} + self.created_region_names = set(regions_by_name) - while regions_to_check: - next_region = regions_to_check.pop() - region_obj = regions_by_name[next_region] + world.multiworld.regions += regions_by_name.values() - for exit in region_obj.exits: - target = exit.connected_region - - if target.name in reachable_regions: - continue - - regions_to_check.add(target.name) - reachable_regions.add(target.name) - - self.created_regions = {k: v for k, v in regions_by_name.items() if k in reachable_regions} - - world.multiworld.regions += self.created_regions.values() + for region_name, region in regions_to_create.items(): + for connection in player_logic.CONNECTIONS_BY_REGION_NAME[region_name]: + self.connect_if_possible(world, region_name, connection[0], connection[1], regions_by_name) def __init__(self, player_locations: WitnessPlayerLocations, world: "WitnessWorld") -> None: difficulty = world.options.puzzle_randomization @@ -148,5 +133,5 @@ def __init__(self, player_locations: WitnessPlayerLocations, world: "WitnessWorl self.reference_logic = static_witness_logic.vanilla self.player_locations = player_locations - self.created_entrances: Dict[Tuple[str, str], List[Entrance]] = defaultdict(lambda: []) - self.created_regions: Dict[str, Region] = dict() + self.two_way_entrance_register: Dict[Tuple[str, str], List[Entrance]] = defaultdict(lambda: []) + self.created_region_names: Set[str] = set() diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index 6445545e9b7a..b4982d1830b2 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -2,15 +2,14 @@ Defines the rules by which locations can be accessed, depending on the items received """ - -from typing import TYPE_CHECKING, FrozenSet +from typing import TYPE_CHECKING from BaseClasses import CollectionState from worlds.generic.Rules import CollectionRule, set_rule -from . import WitnessPlayerRegions from .data import static_logic as static_witness_logic +from .data.utils import WitnessRule from .locations import WitnessPlayerLocations from .player_logic import WitnessPlayerLogic @@ -32,8 +31,7 @@ ] -def _has_laser(laser_hex: str, world: "WitnessWorld", player: int, - redirect_required: bool) -> CollectionRule: +def _has_laser(laser_hex: str, world: "WitnessWorld", player: int, redirect_required: bool) -> CollectionRule: if laser_hex == "0x012FB" and redirect_required: return lambda state: ( _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations)(state) @@ -69,95 +67,164 @@ def _can_solve_panel(panel: str, world: "WitnessWorld", player: int, player_logi return make_lambda(panel, world) -def _can_move_either_direction(state: CollectionState, source: str, target: str, - player_regions: WitnessPlayerRegions) -> bool: - entrance_forward = player_regions.created_entrances[source, target] - entrance_backward = player_regions.created_entrances[target, source] +def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: + """ + For Expert PP2, you need a way to access PP2 from the front, and a separate way from the back. + This condition is quite complicated. We'll attempt to evaluate it as lazily as possible. + """ + + player = world.player + player_regions = world.player_regions - return ( - any(entrance.can_reach(state) for entrance in entrance_forward) - or - any(entrance.can_reach(state) for entrance in entrance_backward) + front_access = ( + any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 2nd Pressure Plate", "Keep"]) + and state.can_reach_region("Keep", player) ) + # If we don't have front access, we can't do PP2. + if not front_access: + return False -def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: - player = world.player + # Front access works. Now, we need to check for the many ways to access PP2 from the back. + # All of those ways lead through the PP3 exit door from PP4. So we check this first. + + fourth_to_third = any(e.can_reach(state) for e in player_regions.two_way_entrance_register[ + "Keep 3rd Pressure Plate", "Keep 4th Pressure Plate" + ]) + + # If we can't get from PP4 to PP3, we can't do PP2. + if not fourth_to_third: + return False - hedge_2_access = ( - _can_move_either_direction(state, "Keep 2nd Maze", "Keep", world.player_regions) + # We can go from PP4 to PP3. We now need to find a way to PP4. + # The shadows shortcut is the simplest way. + + shadows_shortcut = ( + any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 4th Pressure Plate", "Shadows"]) ) - hedge_3_access = ( - _can_move_either_direction(state, "Keep 3rd Maze", "Keep", world.player_regions) - or _can_move_either_direction(state, "Keep 3rd Maze", "Keep 2nd Maze", world.player_regions) - and hedge_2_access + if shadows_shortcut: + return True + + # We don't have the Shadows shortcut. This means we need to come in through the PP4 exit door instead. + + tower_to_pp4 = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 4th Pressure Plate", "Keep Tower"] ) - hedge_4_access = ( - _can_move_either_direction(state, "Keep 4th Maze", "Keep", world.player_regions) - or _can_move_either_direction(state, "Keep 4th Maze", "Keep 3rd Maze", world.player_regions) - and hedge_3_access + # If we don't have the PP4 exit door, we've run out of options. + if not tower_to_pp4: + return False + + # We have the PP4 exit door. If we can get to Keep Tower from behind, we can do PP2. + # The simplest way would be the Tower Shortcut. + + tower_shortcut = any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep", "Keep Tower"]) + + if tower_shortcut: + return True + + # We don't have the Tower shortcut. At this point, there is one possibility remaining: + # Getting to Keep Tower through the hedge mazes. This can be done in a multitude of ways. + # No matter what, though, we would need Hedge Maze 4 Exit to Keep Tower. + + tower_access_from_hedges = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 4th Maze", "Keep Tower"] ) - hedge_access = ( - _can_move_either_direction(state, "Keep 4th Maze", "Keep Tower", world.player_regions) - and state.can_reach("Keep", "Region", player) - and hedge_4_access + if not tower_access_from_hedges: + return False + + # We can reach Keep Tower from Hedge Maze 4. If we now have the Hedge 4 Shortcut, we are immediately good. + + hedge_4_shortcut = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 4th Maze", "Keep"] ) - backwards_to_fourth = ( - state.can_reach("Keep", "Region", player) - and _can_move_either_direction(state, "Keep 4th Pressure Plate", "Keep Tower", world.player_regions) - and ( - _can_move_either_direction(state, "Keep", "Keep Tower", world.player_regions) - or hedge_access - ) + # If we have the hedge 4 shortcut, that works. + if hedge_4_shortcut: + return True + + # We don't have the hedge 4 shortcut. This means we would now need to come through Hedge Maze 3. + + hedge_3_to_4 = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 4th Maze", "Keep 3rd Maze"] ) - shadows_shortcut = ( - state.can_reach("Main Island", "Region", player) - and _can_move_either_direction(state, "Keep 4th Pressure Plate", "Shadows", world.player_regions) + if not hedge_3_to_4: + return False + + # We can get to Hedge 4 from Hedge 3. If we have the Hedge 3 Shortcut, we're good. + + hedge_3_shortcut = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 3rd Maze", "Keep"] ) - backwards_access = ( - _can_move_either_direction(state, "Keep 3rd Pressure Plate", "Keep 4th Pressure Plate", world.player_regions) - and (backwards_to_fourth or shadows_shortcut) + if hedge_3_shortcut: + return True + + # We don't have Hedge 3 Shortcut. This means we would now need to come through Hedge Maze 2. + + hedge_2_to_3 = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 3rd Maze", "Keep 2nd Maze"] ) - front_access = ( - _can_move_either_direction(state, "Keep 2nd Pressure Plate", "Keep", world.player_regions) - and state.can_reach("Keep", "Region", player) + if not hedge_2_to_3: + return False + + # We can get to Hedge 3 from Hedge 2. If we can get from Keep to Hedge 2, we're good. + # This covers both Hedge 1 Exit and Hedge 2 Shortcut, because Hedge 1 is just part of the Keep region. + + hedge_2_from_keep = any( + e.can_reach(state) for e in player_regions.two_way_entrance_register["Keep 2nd Maze", "Keep"] ) - return front_access and backwards_access + return hedge_2_from_keep def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") -> bool: + """ + To do Tunnels Theater Flowers EP, you need to quickly move from Theater to Tunnels. + This condition is a little tricky. We'll attempt to evaluate it as lazily as possible. + """ + + # Checking for access to Theater is not necessary, as solvability of Tutorial Video is checked in the other half + # of the Theater Flowers EP condition. + + player_regions = world.player_regions + direct_access = ( - _can_move_either_direction(state, "Tunnels", "Windmill Interior", world.player_regions) - and _can_move_either_direction(state, "Theater", "Windmill Interior", world.player_regions) + any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Tunnels", "Windmill Interior"]) + and any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Theater", "Windmill Interior"]) ) - theater_from_town = ( - _can_move_either_direction(state, "Town", "Windmill Interior", world.player_regions) - and _can_move_either_direction(state, "Theater", "Windmill Interior", world.player_regions) - or _can_move_either_direction(state, "Town", "Theater", world.player_regions) - ) + if direct_access: + return True + + # We don't have direct access through the shortest path. + # This means we somehow need to exit Theater to the Main Island, and then enter Tunnels from the Main Island. + # Getting to Tunnels through Mountain -> Caves -> Tunnels is way too slow, so we only expect paths through Town. + + # We need a way from Theater to Town. This is actually guaranteed, otherwise we wouldn't be in Theater. + # The only ways to Theater are through Town and Tunnels. We just checked the Tunnels way. + # This might need to be changed when warps are implemented. + + # We also need a way from Town to Tunnels. tunnels_from_town = ( - _can_move_either_direction(state, "Tunnels", "Windmill Interior", world.player_regions) - and _can_move_either_direction(state, "Town", "Windmill Interior", world.player_regions) - or _can_move_either_direction(state, "Tunnels", "Town", world.player_regions) + any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Tunnels", "Windmill Interior"]) + and any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Town", "Windmill Interior"]) + or any(e.can_reach(state) for e in player_regions.two_way_entrance_register["Tunnels", "Town"]) ) - return direct_access or theater_from_town and tunnels_from_town + return tunnels_from_town def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic, player_locations: WitnessPlayerLocations) -> CollectionRule: if item in player_logic.REFERENCE_LOGIC.ALL_REGIONS_BY_NAME: - return lambda state: state.can_reach(item, "Region", player) + region = world.get_region(item) + return region.can_reach if item == "7 Lasers": laser_req = world.options.mountain_lasers.value return _has_lasers(laser_req, world, False) @@ -181,8 +248,7 @@ def _has_item(item: str, world: "WitnessWorld", player: int, return lambda state: state.has(prog_item, player, player_logic.MULTI_AMOUNTS[item]) -def _meets_item_requirements(requirements: FrozenSet[FrozenSet[str]], - world: "WitnessWorld") -> CollectionRule: +def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") -> CollectionRule: """ Checks whether item and panel requirements are met for a panel