diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index ee5eba91..6229e5ff 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -204,8 +204,11 @@ class WitnessWorld(World): ] if early_items: random_early_item = self.random.choice(early_items) - if self.options.puzzle_randomization == "sigma_expert" or self.options.victory_condition == "panel_hunt": - # In Expert, only tag the item as early, rather than forcing it onto the gate. + if ( + self.options.puzzle_randomization == "sigma_expert" + or self.options.victory_condition == "panel_hunt" + ): + # In Expert and Panel Hunt, only tag the item as early, rather than forcing it onto the gate. self.multiworld.local_early_items[self.player][random_early_item] = 1 else: # Force the item onto the tutorial gate check and remove it from our random pool. diff --git a/worlds/witness/data/WitnessLogic.txt b/worlds/witness/data/WitnessLogic.txt index b7814626..fabd1428 100644 --- a/worlds/witness/data/WitnessLogic.txt +++ b/worlds/witness/data/WitnessLogic.txt @@ -176,6 +176,7 @@ Door - 0x03444 (Vault Door) - 0x0CC7B Door - 0x09FEE (Light Room Entry) - 0x0C339 158701 - 0x03608 (Laser Panel) - 0x012D7 & 0x0A15F - True Laser - 0x012FB (Laser) - 0x03608 +159804 - 0xFFD03 (Laser Activated + Redirected) - 0x09F98 & 0x012FB - True 159020 - 0x3351D (Sand Snake EP) - True - True 159030 - 0x0053C (Facade Right EP) - True - True 159031 - 0x00771 (Facade Left EP) - True - True @@ -980,7 +981,7 @@ Mountainside Obelisk (Mountainside) - Entry - True: 159739 - 0x00367 (Obelisk) - True - True Mountainside (Mountainside) - Main Island - True - Mountaintop - True - Mountainside Vault - 0x00085: -159550 - 0x28B91 (Thundercloud EP) - 0x09F98 & 0x012FB - True +159550 - 0x28B91 (Thundercloud EP) - 0xFFD03 - True 158612 - 0x17C42 (Discard) - True - Triangles 158665 - 0x002A6 (Vault Panel) - True - Symmetry & Colored Dots & Black/White Squares & Dots Door - 0x00085 (Vault Door) - 0x002A6 diff --git a/worlds/witness/data/WitnessLogicExpert.txt b/worlds/witness/data/WitnessLogicExpert.txt index 1d1d010f..200138de 100644 --- a/worlds/witness/data/WitnessLogicExpert.txt +++ b/worlds/witness/data/WitnessLogicExpert.txt @@ -176,6 +176,7 @@ Door - 0x03444 (Vault Door) - 0x0CC7B Door - 0x09FEE (Light Room Entry) - 0x0C339 158701 - 0x03608 (Laser Panel) - 0x012D7 & 0x0A15F - True Laser - 0x012FB (Laser) - 0x03608 +159804 - 0xFFD03 (Laser Activated + Redirected) - 0x09F98 & 0x012FB - True 159020 - 0x3351D (Sand Snake EP) - True - True 159030 - 0x0053C (Facade Right EP) - True - True 159031 - 0x00771 (Facade Left EP) - True - True @@ -980,7 +981,7 @@ Mountainside Obelisk (Mountainside) - Entry - True: 159739 - 0x00367 (Obelisk) - True - True Mountainside (Mountainside) - Main Island - True - Mountaintop - True - Mountainside Vault - 0x00085: -159550 - 0x28B91 (Thundercloud EP) - 0x09F98 & 0x012FB - True +159550 - 0x28B91 (Thundercloud EP) - 0xFFD03 - True 158612 - 0x17C42 (Discard) - True - Arrows 158665 - 0x002A6 (Vault Panel) - True - Symmetry & Colored Squares & Triangles & Stars & Stars + Same Colored Symbol Door - 0x00085 (Vault Door) - 0x002A6 diff --git a/worlds/witness/data/WitnessLogicVanilla.txt b/worlds/witness/data/WitnessLogicVanilla.txt index 851031ab..67a42ba7 100644 --- a/worlds/witness/data/WitnessLogicVanilla.txt +++ b/worlds/witness/data/WitnessLogicVanilla.txt @@ -176,6 +176,7 @@ Door - 0x03444 (Vault Door) - 0x0CC7B Door - 0x09FEE (Light Room Entry) - 0x0C339 158701 - 0x03608 (Laser Panel) - 0x012D7 & 0x0A15F - True Laser - 0x012FB (Laser) - 0x03608 +159804 - 0xFFD03 (Laser Activated + Redirected) - 0x09F98 & 0x012FB - True 159020 - 0x3351D (Sand Snake EP) - True - True 159030 - 0x0053C (Facade Right EP) - True - True 159031 - 0x00771 (Facade Left EP) - True - True @@ -980,7 +981,7 @@ Mountainside Obelisk (Mountainside) - Entry - True: 159739 - 0x00367 (Obelisk) - True - True Mountainside (Mountainside) - Main Island - True - Mountaintop - True - Mountainside Vault - 0x00085: -159550 - 0x28B91 (Thundercloud EP) - 0x09F98 & 0x012FB - True +159550 - 0x28B91 (Thundercloud EP) - 0xFFD03 - True 158612 - 0x17C42 (Discard) - True - Triangles 158665 - 0x002A6 (Vault Panel) - True - Symmetry & Colored Dots & Black/White Squares Door - 0x00085 (Vault Door) - 0x002A6 diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index cd1d38f6..09c3f0b1 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -712,8 +712,7 @@ def get_compact_hint_args(hint: WitnessWordedHint, local_player_number: int) -> if hint.vague_location_hint and location.player == local_player_number: assert hint.area is not None # A local vague location hint should have an area argument return location.address, "containing_area:" + hint.area - else: - return location.address, location.player # Scouting does not matter for other players (currently) + return location.address, location.player # Scouting does not matter for other players (currently) # Is junk / undefined hint return -1, local_player_number diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 3e09fe2d..4142ea5e 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -42,7 +42,7 @@ class WitnessPlayerItems: player_locations: WitnessPlayerLocations) -> None: """Adds event items after logic changes due to options""" - self._world: "WitnessWorld" = world + self._world: WitnessWorld = world self._multiworld: MultiWorld = world.multiworld self._player_id: int = world.player self._logic: WitnessPlayerLogic = player_logic diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index b0e330c9..027d1834 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -116,18 +116,19 @@ class WitnessPlayerLogic: self.HUNT_ENTITIES: Set[str] = set() self.ALWAYS_EVENT_NAMES_BY_HEX = { - "0x00509": "+1 Laser (Symmetry Laser)", - "0x012FB": "+1 Laser (Desert Laser)", + "0x00509": "+1 Laser", + "0x012FB": "+1 Laser (Unredirected)", "0x09F98": "Desert Laser Redirection", - "0x01539": "+1 Laser (Quarry Laser)", - "0x181B3": "+1 Laser (Shadows Laser)", - "0x014BB": "+1 Laser (Keep Laser)", - "0x17C65": "+1 Laser (Monastery Laser)", - "0x032F9": "+1 Laser (Town Laser)", - "0x00274": "+1 Laser (Jungle Laser)", - "0x0C2B2": "+1 Laser (Bunker Laser)", - "0x00BF6": "+1 Laser (Swamp Laser)", - "0x028A4": "+1 Laser (Treehouse Laser)", + "0xFFD03": "+1 Laser (Redirected)", + "0x01539": "+1 Laser", + "0x181B3": "+1 Laser", + "0x014BB": "+1 Laser", + "0x17C65": "+1 Laser", + "0x032F9": "+1 Laser", + "0x00274": "+1 Laser", + "0x0C2B2": "+1 Laser", + "0x00BF6": "+1 Laser", + "0x028A4": "+1 Laser", "0x17C34": "Mountain Entry", "0xFFF00": "Bottom Floor Discard Turns On", } diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 6d1f8093..7ff8c440 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -3,7 +3,7 @@ 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, List, Set, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple from BaseClasses import Entrance, Region @@ -38,7 +38,7 @@ class WitnessPlayerRegions: self.created_region_names: Set[str] = set() @staticmethod - def make_lambda(item_requirement: WitnessRule, world: "WitnessWorld") -> CollectionRule: + def make_lambda(item_requirement: WitnessRule, world: "WitnessWorld") -> Optional[CollectionRule]: from .rules import _meets_item_requirements """ @@ -79,7 +79,9 @@ class WitnessPlayerRegions: source_region ) - connection.access_rule = self.make_lambda(final_requirement, world) + rule = self.make_lambda(final_requirement, world) + if rule is not None: + connection.access_rule = rule source_region.exits.append(connection) connection.connect(target_region) diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index eecea8f3..2f3210a2 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -2,7 +2,8 @@ Defines the rules by which locations can be accessed, depending on the items received """ -from typing import TYPE_CHECKING +from collections import Counter +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Union from BaseClasses import CollectionState @@ -15,50 +16,22 @@ from .player_logic import WitnessPlayerLogic if TYPE_CHECKING: from . import WitnessWorld -laser_hexes = [ - "0x028A4", - "0x00274", - "0x032F9", - "0x01539", - "0x181B3", - "0x0C2B2", - "0x00509", - "0x00BF6", - "0x014BB", - "0x012FB", - "0x17C65", -] + +class SimpleItemRepresentation(NamedTuple): + item_name: str + item_count: int -def _can_do_panel_hunt(world: "WitnessWorld") -> CollectionRule: +def _can_do_panel_hunt(world: "WitnessWorld") -> SimpleItemRepresentation: required = world.panel_hunt_required_count - player = world.player - return lambda state: state.has("+1 Panel Hunt", player, required) - - -def _has_laser(laser_hex: str, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: - player = world.player - laser_name = static_witness_logic.ENTITIES_BY_HEX[laser_hex]["checkName"] - - # Workaround for intentional naming inconsistency - if laser_name == "Symmetry Island Laser": - laser_name = "Symmetry Laser" - - if laser_hex == "0x012FB" and redirect_required: - return lambda state: state.has_all([f"+1 Laser ({laser_name})", "Desert Laser Redirection"], player) - - return lambda state: state.has(f"+1 Laser ({laser_name})", player) + return SimpleItemRepresentation("+1 Panel Hunt", required) def _has_lasers(amount: int, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: - laser_lambdas = [] + if redirect_required: + return lambda state: state.has_from_list(["+1 Laser", "+1 Laser (Redirected)"], world.player, amount) - for laser_hex in laser_hexes: - has_laser_lambda = _has_laser(laser_hex, world, redirect_required) - - laser_lambdas.append(has_laser_lambda) - - return lambda state: sum(laser_lambda(state) for laser_lambda in laser_lambdas) >= amount + return lambda state: state.has_from_list(["+1 Laser", "+1 Laser (Unredirected)"], world.player, amount) def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: @@ -196,7 +169,13 @@ def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") -> ) -def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic) -> CollectionRule: +def _has_item(item: str, world: "WitnessWorld", + player_logic: WitnessPlayerLogic) -> Union[CollectionRule, SimpleItemRepresentation]: + """ + Convert a single element of a WitnessRule into a CollectionRule, unless it is referring to an item, + in which case we return it as an item-count pair ("SimpleItemRepresentation"). This allows some optimisation later. + """ + assert item not in static_witness_logic.ENTITIES_BY_HEX, "Requirements can no longer contain entity hexes directly." if item in player_logic.REFERENCE_LOGIC.ALL_REGIONS_BY_NAME: @@ -223,27 +202,90 @@ def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: Witne return lambda state: _can_do_theater_to_tunnels(state, world) prog_item = static_witness_logic.get_parent_progressive_item(item) - return lambda state: state.has(prog_item, player, player_logic.MULTI_AMOUNTS[item]) + needed_amount = player_logic.MULTI_AMOUNTS[item] + + simple_rule: SimpleItemRepresentation = SimpleItemRepresentation(prog_item, needed_amount) + return simple_rule -def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") -> CollectionRule: +def optimize_requirement_option(requirement_option: List[Union[CollectionRule, SimpleItemRepresentation]])\ + -> List[Union[CollectionRule, SimpleItemRepresentation]]: """ - Checks whether item and panel requirements are met for - a panel + This optimises out a requirement like [("Progressive Dots": 1), ("Progressive Dots": 2)] to only the "2" version. """ - lambda_conversion = [ - [_has_item(item, world, world.player, world.player_logic) for item in subset] + direct_items = [rule for rule in requirement_option if isinstance(rule, tuple)] + if not direct_items: + return requirement_option + + max_per_item: Dict[str, int] = Counter() + for item_rule in direct_items: + max_per_item[item_rule[0]] = max(max_per_item[item_rule[0]], item_rule[1]) + + return [ + rule for rule in requirement_option + if not (isinstance(rule, tuple) and rule[1] < max_per_item[rule[0]]) + ] + + +def convert_requirement_option(requirement: List[Union[CollectionRule, SimpleItemRepresentation]], + player: int) -> List[CollectionRule]: + """ + Converts a list of CollectionRules and SimpleItemRepresentations to just a list of CollectionRules. + If the list is ONLY SimpleItemRepresentations, we can just return a CollectionRule based on state.has_all_counts() + """ + converted_sublist = [] + + for rule in requirement: + if not isinstance(rule, tuple): + converted_sublist.append(rule) + continue + + collection_rules = [rule for rule in requirement if not isinstance(rule, SimpleItemRepresentation)] + item_rules = [rule for rule in requirement if isinstance(rule, SimpleItemRepresentation)] + + if len(item_rules) == 0: + item_rules_converted = [] + elif len(item_rules) == 1: + item = item_rules[0][0] + count = item_rules[0][1] + item_rules_converted = [lambda state: state.has(item, player, count)] + else: + item_counts = {item_rule.item_name: item_rule.item_count for item_rule in item_rules} + item_rules_converted = [lambda state: state.has_all_counts(item_counts, player)] + + return collection_rules + item_rules_converted + + +def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") -> Optional[CollectionRule]: + """ + Converts a WitnessRule into a CollectionRule. + """ + player = world.player + + if requirements == frozenset({frozenset()}): + return None + + rule_conversion = [ + [_has_item(item, world, world.player_logic) for item in subset] for subset in requirements ] + optimized_rule_conversion = [optimize_requirement_option(sublist) for sublist in rule_conversion] + + fully_converted_rules = [convert_requirement_option(sublist, player) for sublist in optimized_rule_conversion] + + if len(fully_converted_rules) == 1: + if len(fully_converted_rules[0]) == 1: + return fully_converted_rules[0][0] + return lambda state: all(condition(state) for condition in fully_converted_rules[0]) return lambda state: any( all(condition(state) for condition in sub_requirement) - for sub_requirement in lambda_conversion + for sub_requirement in fully_converted_rules ) -def make_lambda(entity_hex: str, world: "WitnessWorld") -> CollectionRule: +def make_lambda(entity_hex: str, world: "WitnessWorld") -> Optional[CollectionRule]: """ Lambdas are created in a for loop so values need to be captured """ @@ -268,6 +310,8 @@ def set_rules(world: "WitnessWorld") -> None: entity_hex = associated_entity["entity_hex"] rule = make_lambda(entity_hex, world) + if rule is None: + continue location = world.get_location(location) diff --git a/worlds/witness/test/__init__.py b/worlds/witness/test/__init__.py index d1b90ca4..4453609d 100644 --- a/worlds/witness/test/__init__.py +++ b/worlds/witness/test/__init__.py @@ -1,10 +1,11 @@ -from test.bases import WorldTestBase -from test.general import gen_steps, setup_multiworld -from test.multiworld.test_multiworlds import MultiworldTestBase from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union, cast from BaseClasses import CollectionState, Entrance, Item, Location, Region +from test.bases import WorldTestBase +from test.general import gen_steps, setup_multiworld +from test.multiworld.test_multiworlds import MultiworldTestBase + from .. import WitnessWorld diff --git a/worlds/witness/test/test_panel_hunt.py b/worlds/witness/test/test_panel_hunt.py index af6855dc..2f843480 100644 --- a/worlds/witness/test/test_panel_hunt.py +++ b/worlds/witness/test/test_panel_hunt.py @@ -1,5 +1,6 @@ -from BaseClasses import CollectionState, Item -from worlds.witness.test import WitnessTestBase, WitnessMultiworldTestBase +from BaseClasses import CollectionState + +from worlds.witness.test import WitnessMultiworldTestBase, WitnessTestBase class TestMaxPanelHuntMinChecks(WitnessTestBase): @@ -13,7 +14,7 @@ class TestMaxPanelHuntMinChecks(WitnessTestBase): "shuffle_vault_boxes": False, } - def test_correct_panels_were_picked(self): + def test_correct_panels_were_picked(self) -> None: with self.subTest("Check that 100 Hunt Panels were actually picked."): self.assertEqual(len(self.multiworld.find_item_locations("+1 Panel Hunt", self.player)), 100) @@ -63,45 +64,45 @@ class TestPanelHuntPostgame(WitnessMultiworldTestBase): "shuffle_discarded_panels": True, } - def test_panel_hunt_postgame(self): + def test_panel_hunt_postgame(self) -> None: for player_minus_one, options in enumerate(self.options_per_world): player = player_minus_one + 1 postgame_option = options["panel_hunt_postgame"] - with self.subTest(f"Test that \"{postgame_option}\" results in 40 Hunt Panels."): + with self.subTest(f'Test that "{postgame_option}" results in 40 Hunt Panels.'): self.assertEqual(len(self.multiworld.find_item_locations("+1 Panel Hunt", player)), 40) # Test that the box gets extra checks from panel_hunt_postgame - with self.subTest("Test that \"everything_is_eligible\" has no Mountaintop Box Hunt Panels."): + with self.subTest('Test that "everything_is_eligible" has no Mountaintop Box Hunt Panels.'): self.assert_location_does_not_exist("Mountaintop Box Short (Panel Hunt)", 1, strict_check=False) self.assert_location_does_not_exist("Mountaintop Box Long (Panel Hunt)", 1, strict_check=False) - with self.subTest("Test that \"disable_mountain_lasers_locations\" has a Hunt Panel for Short, but not Long."): + with self.subTest('Test that "disable_mountain_lasers_locations" has a Hunt Panel for Short, but not Long.'): self.assert_location_exists("Mountaintop Box Short (Panel Hunt)", 2, strict_check=False) self.assert_location_does_not_exist("Mountaintop Box Long (Panel Hunt)", 2, strict_check=False) - with self.subTest("Test that \"disable_challenge_lasers_locations\" has a Hunt Panel for Long, but not Short."): + with self.subTest('Test that "disable_challenge_lasers_locations" has a Hunt Panel for Long, but not Short.'): self.assert_location_does_not_exist("Mountaintop Box Short (Panel Hunt)", 3, strict_check=False) self.assert_location_exists("Mountaintop Box Long (Panel Hunt)", 3, strict_check=False) - with self.subTest("Test that \"disable_anything_locked_by_lasers\" has both Mountaintop Box Hunt Panels."): + with self.subTest('Test that "disable_anything_locked_by_lasers" has both Mountaintop Box Hunt Panels.'): self.assert_location_exists("Mountaintop Box Short (Panel Hunt)", 4, strict_check=False) self.assert_location_exists("Mountaintop Box Long (Panel Hunt)", 4, strict_check=False) # Check panel_hunt_postgame locations get disabled - with self.subTest("Test that \"everything_is_eligible\" does not disable any locked-by-lasers panels."): + with self.subTest('Test that "everything_is_eligible" does not disable any locked-by-lasers panels.'): self.assert_location_exists("Mountain Floor 1 Right Row 5", 1) self.assert_location_exists("Mountain Bottom Floor Discard", 1) - with self.subTest("Test that \"disable_mountain_lasers_locations\" disables only Shortbox-Locked panels."): + with self.subTest('Test that "disable_mountain_lasers_locations" disables only Shortbox-Locked panels.'): self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 2) self.assert_location_exists("Mountain Bottom Floor Discard", 2) - with self.subTest("Test that \"disable_challenge_lasers_locations\" disables only Longbox-Locked panels."): + with self.subTest('Test that "disable_challenge_lasers_locations" disables only Longbox-Locked panels.'): self.assert_location_exists("Mountain Floor 1 Right Row 5", 3) self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 3) - with self.subTest("Test that \"everything_is_eligible\" disables only Shortbox-Locked panels."): + with self.subTest('Test that "everything_is_eligible" disables only Shortbox-Locked panels.'): self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 4) self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 4)