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
This commit is contained in:
NewSoupVi 2024-06-01 23:11:28 +02:00 committed by GitHub
parent da33d1576a
commit e49b1f9fbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 643 additions and 518 deletions

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -1,4 +0,0 @@
Disabled Locations:
0x03549 (Challenge Video)
0x339B6 (Eclipse EP)

View File

@ -1,2 +0,0 @@
Disabled Locations:
0x17FA2 (Mountain Bottom Floor Discard)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 @@ from .item_definition_classes import (
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 @@ class StaticWitnessLogicObj:
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 @@ class StaticWitnessLogicObj:
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 @@ class StaticWitnessLogicObj:
required_items = frozenset(required_items)
requirement = {
"panels": required_panels,
"entities": required_panels,
"items": required_items
}
@ -145,7 +151,37 @@ class StaticWitnessLogicObj:
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 @@ class StaticWitnessLogicObj:
# 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 @@ class StaticWitnessLogicObj:
self.ENTITY_ID_TO_NAME = dict()
self.read_logic_file(lines)
self.reverse_connections()
self.combine_connections()
# Item data parsed from WitnessItems.txt

View File

@ -2,7 +2,14 @@ from functools import lru_cache
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))

View File

@ -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]

View File

@ -17,13 +17,39 @@ When the world has parsed its options, a second function is called to finalize t
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 @@ if TYPE_CHECKING:
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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"}
# Now that we have item requirements and entity dependencies, it's time for the dependency reduction.
these_panels = frozenset({panels - disabled_eps
for panels in these_panels})
if these_panels == frozenset({frozenset()}):
return these_items
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)
dependent_items_for_option = utils.dnf_and([dependent_items_for_option, new_items])
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
)
for items_option in these_items:
for dependent_item in dependent_items_for_option:
all_options.add(items_option.union(dependent_item))
dependent_items_for_option = logical_and_witness_rules([dependent_items_for_option, new_items])
return utils.dnf_remove_redundancies(frozenset(all_options))
# 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]))
# or-chain all separate dependent entity options.
return logical_or_witness_rules(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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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())
# 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)"])
# 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 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 @@ class WitnessPlayerLogic:
# 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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
])
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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):
"""
Turns dependent check set into semi-independent check set
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):
"""
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
"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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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()

View File

@ -3,13 +3,14 @@ Defines Region for The Witness, assigns locations to them,
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 @@ class WitnessPlayerRegions:
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 @@ class WitnessPlayerRegions:
# 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 @@ class WitnessPlayerRegions:
# 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 @@ class WitnessPlayerRegions:
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 @@ class WitnessPlayerRegions:
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 @@ class WitnessPlayerRegions:
regions_by_name[region_name] = new_region
for region_name, region in self.reference_logic.ALL_REGIONS_BY_NAME.items():
self.created_region_names = set(regions_by_name)
world.multiworld.regions += regions_by_name.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)
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"}
while regions_to_check:
next_region = regions_to_check.pop()
region_obj = regions_by_name[next_region]
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()
def __init__(self, player_locations: WitnessPlayerLocations, world: "WitnessWorld") -> None:
difficulty = world.options.puzzle_randomization
@ -148,5 +133,5 @@ class WitnessPlayerRegions:
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()

View File

@ -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 @@ laser_hexes = [
]
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]
return (
any(entrance.can_reach(state) for entrance in entrance_forward)
or
any(entrance.can_reach(state) for entrance in entrance_backward)
)
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
hedge_2_access = (
_can_move_either_direction(state, "Keep 2nd Maze", "Keep", world.player_regions)
)
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
)
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
)
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
)
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
)
)
shadows_shortcut = (
state.can_reach("Main Island", "Region", player)
and _can_move_either_direction(state, "Keep 4th Pressure Plate", "Shadows", world.player_regions)
)
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)
)
player_regions = world.player_regions
front_access = (
_can_move_either_direction(state, "Keep 2nd Pressure Plate", "Keep", world.player_regions)
and state.can_reach("Keep", "Region", player)
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)
)
return front_access and backwards_access
# If we don't have front access, we can't do PP2.
if not front_access:
return False
# 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
# 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"])
)
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"]
)
# 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"]
)
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"]
)
# 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"]
)
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"]
)
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"]
)
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 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