From e49b1f9fbb5e8eb98882cc4307541253e5f23c7f 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 001/119] 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 From dedabad290ccec512776f732c3619509d1d091b1 Mon Sep 17 00:00:00 2001 From: Emily <35015090+EmilyV99@users.noreply.github.com> Date: Sun, 2 Jun 2024 12:45:46 -0400 Subject: [PATCH 002/119] APSudoku: take over maintaining hintgame sudoku from bk_sudoku (#3432) --- docs/CODEOWNERS | 6 ++-- worlds/{bk_sudoku => apsudoku}/__init__.py | 25 +++++---------- worlds/apsudoku/docs/en_Sudoku.md | 13 ++++++++ worlds/apsudoku/docs/setup_en.md | 37 ++++++++++++++++++++++ worlds/bk_sudoku/docs/de_Sudoku.md | 21 ------------ worlds/bk_sudoku/docs/en_Sudoku.md | 13 -------- worlds/bk_sudoku/docs/setup_de.md | 27 ---------------- worlds/bk_sudoku/docs/setup_en.md | 24 -------------- 8 files changed, 61 insertions(+), 105 deletions(-) rename worlds/{bk_sudoku => apsudoku}/__init__.py (50%) create mode 100644 worlds/apsudoku/docs/en_Sudoku.md create mode 100644 worlds/apsudoku/docs/setup_en.md delete mode 100644 worlds/bk_sudoku/docs/de_Sudoku.md delete mode 100644 worlds/bk_sudoku/docs/en_Sudoku.md delete mode 100644 worlds/bk_sudoku/docs/setup_de.md delete mode 100644 worlds/bk_sudoku/docs/setup_en.md diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index f54132e24aa0..10b962d49970 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -15,15 +15,15 @@ # A Link to the Past /worlds/alttp/ @Berserker66 +# Sudoku (APSudoku) +/worlds/apsudoku/ @EmilyV99 + # Aquaria /worlds/aquaria/ @tioui # ArchipIDLE /worlds/archipidle/ @LegendaryLinux -# Sudoku (BK Sudoku) -/worlds/bk_sudoku/ @Jarno458 - # Blasphemous /worlds/blasphemous/ @TRPG0 diff --git a/worlds/bk_sudoku/__init__.py b/worlds/apsudoku/__init__.py similarity index 50% rename from worlds/bk_sudoku/__init__.py rename to worlds/apsudoku/__init__.py index 2c57bc7301ff..c6bd02bdc262 100644 --- a/worlds/bk_sudoku/__init__.py +++ b/worlds/apsudoku/__init__.py @@ -3,41 +3,32 @@ from BaseClasses import Tutorial from ..AutoWorld import WebWorld, World - -class Bk_SudokuWebWorld(WebWorld): +class AP_SudokuWebWorld(WebWorld): options_page = "games/Sudoku/info/en" theme = 'partyTime' setup_en = Tutorial( tutorial_name='Setup Guide', - description='A guide to playing BK Sudoku', + description='A guide to playing APSudoku', language='English', file_name='setup_en.md', link='setup/en', - authors=['Jarno'] - ) - setup_de = Tutorial( - tutorial_name='Setup Anleitung', - description='Eine Anleitung um BK-Sudoku zu spielen', - language='Deutsch', - file_name='setup_de.md', - link='setup/de', - authors=['Held_der_Zeit'] + authors=['EmilyV'] ) - tutorials = [setup_en, setup_de] + tutorials = [setup_en] - -class Bk_SudokuWorld(World): +class AP_SudokuWorld(World): """ Play a little Sudoku while you're in BK mode to maybe get some useful hints """ game = "Sudoku" - web = Bk_SudokuWebWorld() + web = AP_SudokuWebWorld() item_name_to_id: Dict[str, int] = {} location_name_to_id: Dict[str, int] = {} @classmethod def stage_assert_generate(cls, multiworld): - raise Exception("BK Sudoku cannot be used for generating worlds, the client can instead connect to any other world") + raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world") + diff --git a/worlds/apsudoku/docs/en_Sudoku.md b/worlds/apsudoku/docs/en_Sudoku.md new file mode 100644 index 000000000000..e81f773e0291 --- /dev/null +++ b/worlds/apsudoku/docs/en_Sudoku.md @@ -0,0 +1,13 @@ +# APSudoku + +## Hint Games + +HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot. + +## What is this game? + +Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random. + +## Where is the options page? + +There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld. diff --git a/worlds/apsudoku/docs/setup_en.md b/worlds/apsudoku/docs/setup_en.md new file mode 100644 index 000000000000..cf2c755bd837 --- /dev/null +++ b/worlds/apsudoku/docs/setup_en.md @@ -0,0 +1,37 @@ +# APSudoku Setup Guide + +## Required Software +- [APSudoku](https://github.com/EmilyV99/APSudoku) +- Windows (most tested on Win10) +- Other platforms might be able to build from source themselves; and may be included in the future. + +## General Concept + +This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations. + +Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files. + +## Installation Procedures + +Go to the latest release from the [APSudoku Releases page](https://github.com/EmilyV99/APSudoku/releases). Download and extract the `APSudoku.zip` file. + +## Joining a MultiWorld Game + +1. Run APSudoku.exe +2. Under the 'Archipelago' tab at the top-right: + - Enter the server url & port number + - Enter the name of the slot you wish to connect to + - Enter the room password (optional) + - Select DeathLink related settings (optional) + - Press connect +3. Go back to the 'Sudoku' tab + - Click the various '?' buttons for information on how to play / control +4. Choose puzzle difficulty +5. Try to solve the Sudoku. Click 'Check' when done. + +## DeathLink Support + +If 'DeathLink' is enabled when you click 'Connect': +- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or quit a puzzle without solving it (including disconnecting). +- Life count customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle. +- On receiving a DeathLink from another player, your puzzle resets. diff --git a/worlds/bk_sudoku/docs/de_Sudoku.md b/worlds/bk_sudoku/docs/de_Sudoku.md deleted file mode 100644 index abb50c5498d1..000000000000 --- a/worlds/bk_sudoku/docs/de_Sudoku.md +++ /dev/null @@ -1,21 +0,0 @@ -# BK-Sudoku - -## Was ist das für ein Spiel? - -BK-Sudoku ist kein typisches Archipelago-Spiel; stattdessen ist es ein gewöhnlicher Sudoku-Client der sich zu jeder -beliebigen Multiworld verbinden kann. Einmal verbunden kannst du ein 9x9 Sudoku spielen um einen zufälligen Hinweis -für dein Spiel zu erhalten. Es ist zwar langsam, aber es gibt dir etwas zu tun, solltest du mal nicht in der Lage sein -weitere „Checks” zu erreichen. -(Wer mag kann auch einfach so Sudoku spielen. Man muss nicht mit einer Multiworld verbunden sein, um ein Sudoku zu -spielen/generieren.) - -## Wie werden Hinweise freigeschalten? - -Nach dem Lösen eines Sudokus wird für den verbundenen Slot ein zufällig ausgewählter Hinweis freigegeben, für einen -Gegenstand der noch nicht gefunden wurde. - -## Wo ist die Seite für die Einstellungen? - -Es gibt keine Seite für die Einstellungen. Dieses Spiel kann nicht in deinen YAML-Dateien benutzt werden. Stattdessen -kann sich der Client mit einem beliebigen Slot einer Multiworld verbinden. In dem Client selbst kann aber der -Schwierigkeitsgrad des Sudoku ausgewählt werden. diff --git a/worlds/bk_sudoku/docs/en_Sudoku.md b/worlds/bk_sudoku/docs/en_Sudoku.md deleted file mode 100644 index dae5a9e3e513..000000000000 --- a/worlds/bk_sudoku/docs/en_Sudoku.md +++ /dev/null @@ -1,13 +0,0 @@ -# Bk Sudoku - -## What is this game? - -BK Sudoku is not a typical Archipelago game; instead, it is a generic Sudoku client that can connect to any existing multiworld. When connected, you can play Sudoku to unlock random hints for your game. While slow, it will give you something to do when you can't reach the checks in your game. - -## What hints are unlocked? - -After completing a Sudoku puzzle, the game will unlock 1 random hint for an unchecked location in the slot you are connected to. - -## Where is the options page? - -There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld. diff --git a/worlds/bk_sudoku/docs/setup_de.md b/worlds/bk_sudoku/docs/setup_de.md deleted file mode 100644 index 71a8e5f6245d..000000000000 --- a/worlds/bk_sudoku/docs/setup_de.md +++ /dev/null @@ -1,27 +0,0 @@ -# BK-Sudoku Setup Anleitung - -## Benötigte Software -- [Bk-Sudoku](https://github.com/Jarno458/sudoku) -- Windows 8 oder höher - -## Generelles Konzept - -Dies ist ein Client, der sich mit jedem beliebigen Slot einer Multiworld verbinden kann. Er lässt dich ein (9x9) Sudoku -spielen, um zufällige Hinweise für den verbundenen Slot freizuschalten. - -Aufgrund des Fakts, dass der Sudoku-Client sich zu jedem beliebigen Slot verbinden kann, ist es daher nicht notwendig -eine YAML für dieses Spiel zu generieren, da es keinen neuen Slot zur Multiworld-Session hinzufügt. - -## Installationsprozess - -Gehe zu der aktuellsten (latest) Veröffentlichung der [BK-Sudoku Releases](https://github.com/Jarno458/sudoku/releases). -Downloade und extrahiere/entpacke die `Bk_Sudoku.zip`-Datei. - -## Verbinden mit einer Multiworld - -1. Starte `Bk_Sudoku.exe` -2. Trage den Namen des Slots ein, mit dem du dich verbinden möchtest -3. Trage die Server-URL und den Port ein -4. Drücke auf Verbinden (connect) -5. Wähle deinen Schwierigkeitsgrad -6. Versuche das Sudoku zu Lösen diff --git a/worlds/bk_sudoku/docs/setup_en.md b/worlds/bk_sudoku/docs/setup_en.md deleted file mode 100644 index eda17e701bb8..000000000000 --- a/worlds/bk_sudoku/docs/setup_en.md +++ /dev/null @@ -1,24 +0,0 @@ -# BK Sudoku Setup Guide - -## Required Software -- [Bk Sudoku](https://github.com/Jarno458/sudoku) -- Windows 8 or higher - -## General Concept - -This is a client that can connect to any multiworld slot, and lets you play Sudoku to unlock random hints for that slot's locations. - -Due to the fact that the Sudoku client may connect to any slot, it is not necessary to generate a YAML for this game as it does not generate any new slots in the multiworld session. - -## Installation Procedures - -Go to the latest release on [BK Sudoku Releases](https://github.com/Jarno458/sudoku/releases). Download and extract the `Bk_Sudoku.zip` file. - -## Joining a MultiWorld Game - -1. Run Bk_Sudoku.exe -2. Enter the name of the slot you wish to connect to -3. Enter the server url & port number -4. Press connect -5. Choose difficulty -6. Try to solve the Sudoku From 6432560fe5643aec302c4cc96761a12a03a5b8a2 Mon Sep 17 00:00:00 2001 From: qwint Date: Sun, 2 Jun 2024 21:39:34 -0500 Subject: [PATCH 003/119] Fix Egg_Shop typo in costsanity (#3447) --- worlds/hk/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index fdaece8d34cd..78287305df5f 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -405,7 +405,7 @@ def _compute_weights(weights: dict, desc: str) -> typing.Dict[str, int]: continue if setting == CostSanity.option_shopsonly and location.basename not in multi_locations: continue - if location.basename in {'Grubfather', 'Seer', 'Eggshop'}: + if location.basename in {'Grubfather', 'Seer', 'Egg_Shop'}: our_weights = dict(weights_geoless) else: our_weights = dict(weights) From 424c8b0be9654f9c8556c1e68fcc093d00f860c6 Mon Sep 17 00:00:00 2001 From: Remy Jette Date: Sun, 2 Jun 2024 22:42:15 -0400 Subject: [PATCH 004/119] Pokemon RB: Add an item group for each HM to improve hinting (#3311) * Pokemon RB: Add an item group for each HM HMs are suffixed with the name of the move, e.g. "HM02 Fly". If TM move are randomized, they do not have the move name, e.g. "TM02". If someone hints for an HM using the just the number, the fuzzy matching sees "TM02" as closer than "HM02 Fly", and in fact sees it as close enough to not ask the user to confirm, leading them to waste hint points on non-progression item that they didn't intend. Emerald already does this for this reason, adding the same for RB. * Add the new groups for HMs in the item_table instead --- worlds/pokemon_rb/items.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/pokemon_rb/items.py b/worlds/pokemon_rb/items.py index 24cad13252b1..de29f341c6df 100644 --- a/worlds/pokemon_rb/items.py +++ b/worlds/pokemon_rb/items.py @@ -119,11 +119,11 @@ def __init__(self, item_id, classification, groups): "Card Key 11F": ItemData(109, ItemClassification.progression, ["Unique", "Key Items", "Card Keys"]), "Progressive Card Key": ItemData(110, ItemClassification.progression, ["Unique", "Key Items", "Card Keys"]), "Sleep Trap": ItemData(111, ItemClassification.trap, ["Traps"]), - "HM01 Cut": ItemData(196, ItemClassification.progression, ["Unique", "HMs", "Key Items"]), - "HM02 Fly": ItemData(197, ItemClassification.progression, ["Unique", "HMs", "Key Items"]), - "HM03 Surf": ItemData(198, ItemClassification.progression, ["Unique", "HMs", "Key Items"]), - "HM04 Strength": ItemData(199, ItemClassification.progression, ["Unique", "HMs", "Key Items"]), - "HM05 Flash": ItemData(200, ItemClassification.progression, ["Unique", "HMs", "Key Items"]), + "HM01 Cut": ItemData(196, ItemClassification.progression, ["Unique", "HMs", "HM01", "Key Items"]), + "HM02 Fly": ItemData(197, ItemClassification.progression, ["Unique", "HMs", "HM02", "Key Items"]), + "HM03 Surf": ItemData(198, ItemClassification.progression, ["Unique", "HMs", "HM03", "Key Items"]), + "HM04 Strength": ItemData(199, ItemClassification.progression, ["Unique", "HMs", "HM04", "Key Items"]), + "HM05 Flash": ItemData(200, ItemClassification.progression, ["Unique", "HMs", "HM05", "Key Items"]), "TM01 Mega Punch": ItemData(201, ItemClassification.useful, ["Unique", "TMs"]), "TM02 Razor Wind": ItemData(202, ItemClassification.filler, ["Unique", "TMs"]), "TM03 Swords Dance": ItemData(203, ItemClassification.useful, ["Unique", "TMs"]), From d9120f0bea7fb564c51d3257e2ed67624d73261f Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Mon, 3 Jun 2024 04:42:27 -0400 Subject: [PATCH 005/119] WebHost: Allowing options that work on WebHost to be used in presets (#3441) --- WebHostLib/options.py | 2 +- test/webhost/test_option_presets.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 62ba86a56626..53c3a6151b82 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -91,7 +91,7 @@ def option_presets(game: str) -> Response: f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." presets[preset_name][preset_option_name] = option.value - elif isinstance(option, Options.Range): + elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)): presets[preset_name][preset_option_name] = option.value elif isinstance(preset_option, str): # Ensure the option value is valid for Choice and Toggle options diff --git a/test/webhost/test_option_presets.py b/test/webhost/test_option_presets.py index 0c88b6c2ee6f..b0af8a871183 100644 --- a/test/webhost/test_option_presets.py +++ b/test/webhost/test_option_presets.py @@ -1,7 +1,7 @@ import unittest from worlds import AutoWorldRegister -from Options import Choice, NamedRange, Toggle, Range +from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet class TestOptionPresets(unittest.TestCase): @@ -14,7 +14,7 @@ def test_option_presets_have_valid_options(self): with self.subTest(game=game_name, preset=preset_name, option=option_name): try: option = world_type.options_dataclass.type_hints[option_name].from_any(option_value) - supported_types = [Choice, Toggle, Range, NamedRange] + supported_types = [NumericOption, OptionSet, OptionList, ItemDict] if not any([issubclass(option.__class__, t) for t in supported_types]): self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' " f"is not a supported type for webhost. " From 70e9ccb13c600072b234027a944a9a190835c37a Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Mon, 3 Jun 2024 04:44:37 -0400 Subject: [PATCH 006/119] TUNIC: Fix plando connections, seed groups, and UT support (#3429) --- worlds/tunic/__init__.py | 10 ++++++---- worlds/tunic/er_scripts.py | 33 +++++++++++++++++++-------------- worlds/tunic/options.py | 4 ++-- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index 9ef5800955aa..624208da3a0b 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -8,7 +8,7 @@ from .regions import tunic_regions from .er_scripts import create_er_regions from .er_data import portal_mapping -from .options import TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets +from .options import TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections from worlds.AutoWorld import WebWorld, World from Options import PlandoConnection from decimal import Decimal, ROUND_HALF_UP @@ -43,7 +43,7 @@ class SeedGroup(TypedDict): logic_rules: int # logic rules value laurels_at_10_fairies: bool # laurels location value fixed_shop: bool # fixed shop value - plando: List[PlandoConnection] # consolidated list of plando connections for the seed group + plando: TunicPlandoConnections # consolidated of plando connections for the seed group class TunicWorld(World): @@ -96,13 +96,15 @@ def generate_early(self) -> None: self.options.hexagon_quest.value = passthrough["hexagon_quest"] self.options.entrance_rando.value = passthrough["entrance_rando"] self.options.shuffle_ladders.value = passthrough["shuffle_ladders"] + self.options.fixed_shop.value = self.options.fixed_shop.option_false + self.options.laurels_location.value = self.options.laurels_location.option_anywhere @classmethod def stage_generate_early(cls, multiworld: MultiWorld) -> None: tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC") for tunic in tunic_worlds: # if it's one of the options, then it isn't a custom seed group - if tunic.options.entrance_rando.value in EntranceRando.options: + if tunic.options.entrance_rando.value in EntranceRando.options.values(): continue group = tunic.options.entrance_rando.value # if this is the first world in the group, set the rules equal to its rules @@ -147,7 +149,7 @@ def stage_generate_early(cls, multiworld: MultiWorld) -> None: f"{tunic.multiworld.get_player_name(tunic.player)}'s plando " f"connection {cxn.entrance} <-> {cxn.exit}") if new_cxn: - cls.seed_groups[group]["plando"].append(cxn) + cls.seed_groups[group]["plando"].value.append(cxn) def create_item(self, name: str) -> TunicItem: item_data = item_table[name] diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 7e022c9f3a0d..9d25137ba469 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -140,7 +140,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: waterfall_plando = False # if it's not one of the EntranceRando options, it's a custom seed - if world.options.entrance_rando.value not in EntranceRando.options: + if world.options.entrance_rando.value not in EntranceRando.options.values(): seed_group = world.seed_groups[world.options.entrance_rando.value] logic_rules = seed_group["logic_rules"] fixed_shop = seed_group["fixed_shop"] @@ -162,6 +162,11 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: portal_map.remove(portal) break + # If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit + if hasattr(world.multiworld, "re_gen_passthrough"): + if "TUNIC" in world.multiworld.re_gen_passthrough: + portal_map = portal_mapping.copy() + # create separate lists for dead ends and non-dead ends for portal in portal_map: dead_end_status = tunic_er_regions[portal.region].dead_end @@ -193,7 +198,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: connected_regions.add(start_region) connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules) - if world.options.entrance_rando.value in EntranceRando.options: + if world.options.entrance_rando.value in EntranceRando.options.values(): plando_connections = world.options.plando_connections.value else: plando_connections = world.seed_groups[world.options.entrance_rando.value]["plando"] @@ -255,7 +260,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: else: # if not both, they're both dead ends if not portal2: - if world.options.entrance_rando.value not in EntranceRando.options: + if world.options.entrance_rando.value not in EntranceRando.options.values(): raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " "end to a dead end in their plando connections.") else: @@ -302,21 +307,21 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]: traversal_reqs.setdefault(portal1.region, dict())[portal2.region] = [] traversal_reqs.setdefault(portal2.region, dict())[portal1.region] = [] - if portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit": - if portal1_dead_end or portal2_dead_end or \ - portal1.region == "Secret Gathering Place" or portal2.region == "Secret Gathering Place": - if world.options.entrance_rando.value not in EntranceRando.options: - raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " - "end to a dead end in their plando connections.") - else: - raise Exception(f"{player_name} paired a dead end to a dead end in their " - "plando connections.") + if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place") + or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")): + if world.options.entrance_rando.value not in EntranceRando.options.values(): + raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " + "end to a dead end in their plando connections.") + else: + raise Exception(f"{player_name} paired a dead end to a dead end in their " + "plando connections.") - if portal1.region == "Secret Gathering Place" or portal2.region == "Secret Gathering Place": + if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit") + or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")): # need to make sure you didn't pair this to a dead end or zig skip if portal1_dead_end or portal2_dead_end or \ portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit": - if world.options.entrance_rando.value not in EntranceRando.options: + if world.options.entrance_rando.value not in EntranceRando.options.values(): raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " "end to a dead end in their plando connections.") else: diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index b3b6b3b96fb0..ff9872ab4807 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -173,7 +173,7 @@ class ShuffleLadders(Toggle): display_name = "Shuffle Ladders" -class TUNICPlandoConnections(PlandoConnections): +class TunicPlandoConnections(PlandoConnections): entrances = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"} exits = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"} @@ -198,7 +198,7 @@ class TunicOptions(PerGameCommonOptions): lanternless: Lanternless maskless: Maskless laurels_location: LaurelsLocation - plando_connections: TUNICPlandoConnections + plando_connections: TunicPlandoConnections tunic_option_groups = [ From cff7327558979c3088ff5ac792a4e3d0149fd4e9 Mon Sep 17 00:00:00 2001 From: Zach Parks Date: Mon, 3 Jun 2024 03:45:01 -0500 Subject: [PATCH 007/119] Utils: Fix mistake made with `KeyedDefaultDict` from #1933 that broke tracker functionality. (#3433) --- Utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Utils.py b/Utils.py index eea81a2d3201..a7fd7f4f334c 100644 --- a/Utils.py +++ b/Utils.py @@ -458,8 +458,14 @@ class KeyedDefaultDict(collections.defaultdict): """defaultdict variant that uses the missing key as argument to default_factory""" default_factory: typing.Callable[[typing.Any], typing.Any] - def __init__(self, default_factory: typing.Callable[[Any], Any] = None, **kwargs): - super().__init__(default_factory, **kwargs) + def __init__(self, + default_factory: typing.Callable[[Any], Any] = None, + seq: typing.Union[typing.Mapping, typing.Iterable, None] = None, + **kwargs): + if seq is not None: + super().__init__(default_factory, seq, **kwargs) + else: + super().__init__(default_factory, **kwargs) def __missing__(self, key): self[key] = value = self.default_factory(key) From fb2c194e3733c3ba200cfff52bf89ddb4c4a6912 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Mon, 3 Jun 2024 04:51:27 -0400 Subject: [PATCH 008/119] Lingo: Fix Basement access with THE MASTER (#3231) --- worlds/lingo/player_logic.py | 19 ++++++++++++++----- worlds/lingo/regions.py | 9 ++++++++- worlds/lingo/rules.py | 9 +++------ worlds/lingo/test/TestMastery.py | 19 ++++++++++++++++++- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/worlds/lingo/player_logic.py b/worlds/lingo/player_logic.py index b6941f37eed1..1621620e1e14 100644 --- a/worlds/lingo/player_logic.py +++ b/worlds/lingo/player_logic.py @@ -18,19 +18,23 @@ class AccessRequirements: rooms: Set[str] doors: Set[RoomAndDoor] colors: Set[str] + the_master: bool def __init__(self): self.rooms = set() self.doors = set() self.colors = set() + self.the_master = False def merge(self, other: "AccessRequirements"): self.rooms |= other.rooms self.doors |= other.doors self.colors |= other.colors + self.the_master |= other.the_master def __str__(self): - return f"AccessRequirements(rooms={self.rooms}, doors={self.doors}, colors={self.colors})" + return f"AccessRequirements(rooms={self.rooms}, doors={self.doors}, colors={self.colors})," \ + f" the_master={self.the_master}" class PlayerLocation(NamedTuple): @@ -463,6 +467,9 @@ def calculate_panel_requirements(self, room: str, panel: str, world: "LingoWorld req_panel.panel, world) access_reqs.merge(sub_access_reqs) + if panel == "THE MASTER": + access_reqs.the_master = True + self.panel_reqs[room][panel] = access_reqs return self.panel_reqs[room][panel] @@ -502,15 +509,17 @@ def create_panel_hunt_events(self, world: "LingoWorld"): unhindered_panels_by_color: dict[Optional[str], int] = {} for panel_name, panel_data in room_data.items(): - # We won't count non-counting panels. THE MASTER has special access rules and is handled separately. - if panel_data.non_counting or panel_name == "THE MASTER": + # We won't count non-counting panels. + if panel_data.non_counting: continue # We won't coalesce any panels that have requirements beyond colors. To simplify things for now, we will - # only coalesce single-color panels. Chains/stacks/combo puzzles will be separate. + # only coalesce single-color panels. Chains/stacks/combo puzzles will be separate. THE MASTER has + # special access rules and is handled separately. if len(panel_data.required_panels) > 0 or len(panel_data.required_doors) > 0\ or len(panel_data.required_rooms) > 0\ - or (world.options.shuffle_colors and len(panel_data.colors) > 1): + or (world.options.shuffle_colors and len(panel_data.colors) > 1)\ + or panel_name == "THE MASTER": self.counting_panel_reqs.setdefault(room_name, []).append( (self.calculate_panel_requirements(room_name, panel_name, world), 1)) else: diff --git a/worlds/lingo/regions.py b/worlds/lingo/regions.py index 4b357db261b4..9834f04f9de7 100644 --- a/worlds/lingo/regions.py +++ b/worlds/lingo/regions.py @@ -49,8 +49,15 @@ def connect_entrance(regions: Dict[str, Region], source_region: Region, target_r if door is not None: effective_room = target_region.name if door.room is None else door.room if door.door not in world.player_logic.item_by_door.get(effective_room, {}): - for region in world.player_logic.calculate_door_requirements(effective_room, door.door, world).rooms: + access_reqs = world.player_logic.calculate_door_requirements(effective_room, door.door, world) + for region in access_reqs.rooms: world.multiworld.register_indirect_condition(regions[region], connection) + + # This pretty much only applies to Orange Tower Sixth Floor -> Orange Tower Basement. + if access_reqs.the_master: + for mastery_req in world.player_logic.mastery_reqs: + for region in mastery_req.rooms: + world.multiworld.register_indirect_condition(regions[region], connection) if not pilgrimage and world.options.enable_pilgrimage and is_acceptable_pilgrimage_entrance(entrance_type, world)\ and source_region.name != "Menu": diff --git a/worlds/lingo/rules.py b/worlds/lingo/rules.py index 9cc11fdaea31..d91c53f05b47 100644 --- a/worlds/lingo/rules.py +++ b/worlds/lingo/rules.py @@ -42,12 +42,6 @@ def lingo_can_use_level_2_location(state: CollectionState, world: "LingoWorld"): counted_panels += panel_count if counted_panels >= world.options.level_2_requirement.value - 1: return True - # THE MASTER has to be handled separately, because it has special access rules. - if state.can_reach("Orange Tower Seventh Floor", "Region", world.player)\ - and lingo_can_use_mastery_location(state, world): - counted_panels += 1 - if counted_panels >= world.options.level_2_requirement.value - 1: - return True return False @@ -65,6 +59,9 @@ def _lingo_can_satisfy_requirements(state: CollectionState, access: AccessRequir if not state.has(color.capitalize(), world.player): return False + if access.the_master and not lingo_can_use_mastery_location(state, world): + return False + return True diff --git a/worlds/lingo/test/TestMastery.py b/worlds/lingo/test/TestMastery.py index 3fb3c95a0208..6e563393cf7f 100644 --- a/worlds/lingo/test/TestMastery.py +++ b/worlds/lingo/test/TestMastery.py @@ -36,4 +36,21 @@ def test_requirement(self): self.assertFalse(self.can_reach_location("Orange Tower Seventh Floor - Mastery Achievements")) self.collect_by_name(["Green", "Gray", "Brown", "Yellow"]) - self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - Mastery Achievements")) \ No newline at end of file + self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - Mastery Achievements")) + + +class TestMasteryBlocksDependents(LingoTestBase): + options = { + "mastery_achievements": "24", + "shuffle_colors": "true", + "location_checks": "insanity" + } + + def test_requirement(self): + self.collect_all_but("Gray") + self.assertFalse(self.can_reach_location("Orange Tower Basement - THE LIBRARY")) + self.assertFalse(self.can_reach_location("Orange Tower Seventh Floor - MASTERY")) + + self.collect_by_name("Gray") + self.assertTrue(self.can_reach_location("Orange Tower Basement - THE LIBRARY")) + self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - MASTERY")) From c7eef13b335204f750759e75a43034400ec7cc82 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Mon, 3 Jun 2024 10:36:51 -0400 Subject: [PATCH 009/119] Accounting for name change (#3449) --- worlds/lingo/test/TestMastery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/lingo/test/TestMastery.py b/worlds/lingo/test/TestMastery.py index 6e563393cf7f..3ebe40aa22d7 100644 --- a/worlds/lingo/test/TestMastery.py +++ b/worlds/lingo/test/TestMastery.py @@ -49,8 +49,8 @@ class TestMasteryBlocksDependents(LingoTestBase): def test_requirement(self): self.collect_all_but("Gray") self.assertFalse(self.can_reach_location("Orange Tower Basement - THE LIBRARY")) - self.assertFalse(self.can_reach_location("Orange Tower Seventh Floor - MASTERY")) + self.assertFalse(self.can_reach_location("The Fearless - MASTERY")) self.collect_by_name("Gray") self.assertTrue(self.can_reach_location("Orange Tower Basement - THE LIBRARY")) - self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - MASTERY")) + self.assertTrue(self.can_reach_location("The Fearless - MASTERY")) From 06e65c1dc6ce4a1564d1b6924b83a0c9546011ec Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Mon, 3 Jun 2024 18:43:01 -0400 Subject: [PATCH 010/119] WebHost: weighted-options bugfixes (#3448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix improper css for word-break on player-options page * Add default handling to weighted-options types * Remove random-low/mid/high from Toggle, Choice, and TextChoice, * Port key sorting for OptionList and OptionSet from player-options to weighted-options * Ensure Choice and TextChoice values are set properly * Remove debug line 🤦‍♂️ --- .../styles/playerOptions/playerOptions.css | 2 +- .../styles/playerOptions/playerOptions.scss | 2 +- .../templates/weightedOptions/macros.html | 35 +++++++++++-------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css b/WebHostLib/static/styles/playerOptions/playerOptions.css index 6165e3a0f622..56c9263d3330 100644 --- a/WebHostLib/static/styles/playerOptions/playerOptions.css +++ b/WebHostLib/static/styles/playerOptions/playerOptions.css @@ -15,7 +15,7 @@ html { border-radius: 8px; padding: 1rem; color: #eeffeb; - word-break: break-all; + word-break: break-word; } #player-options #player-options-header h1 { margin-bottom: 0; diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.scss b/WebHostLib/static/styles/playerOptions/playerOptions.scss index 525b8ef15403..06bde759d263 100644 --- a/WebHostLib/static/styles/playerOptions/playerOptions.scss +++ b/WebHostLib/static/styles/playerOptions/playerOptions.scss @@ -16,7 +16,7 @@ html{ border-radius: 8px; padding: 1rem; color: #eeffeb; - word-break: break-all; + word-break: break-word; #player-options-header{ h1{ diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index 5b8944a43887..a6e4545fdaf7 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -1,9 +1,9 @@ {% macro Toggle(option_name, option) %} - {{ RangeRow(option_name, option, "No", "false") }} - {{ RangeRow(option_name, option, "Yes", "true") }} - {{ RandomRows(option_name, option) }} + {{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }} + {{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }} + {{ RandomRow(option_name, option) }}
{% endmacro %} @@ -18,10 +18,10 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name|lower else None) }} {% endif %} {% endfor %} - {{ RandomRows(option_name, option) }} + {{ RandomRow(option_name, option) }} {% endmacro %} @@ -72,7 +72,9 @@ - + {% if option.default %} + {{ RangeRow(option_name, option, option.default, option.default) }} + {% endif %}
@@ -90,10 +92,10 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} {% endif %} {% endfor %} - {{ RandomRows(option_name, option) }} + {{ RandomRow(option_name, option) }} {% endmacro %} @@ -112,7 +114,7 @@ type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}" - value="0" + value="{{ option.default[item_name] if item_name in option.default else "0" }}" /> {% endfor %} @@ -121,13 +123,14 @@ {% macro OptionList(option_name, option) %}
- {% for key in option.valid_keys|sort %} + {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}