Archipelago/worlds/witness/player_logic.py

562 lines
24 KiB
Python

"""
Parses the WitnessLogic.txt logic file into useful data structures.
This is the heart of the randomization.
In WitnessLogic.txt we have regions defined with their connections:
Region Name (Short name) - Connected Region 1 - Connection Requirement 1 - Connected Region 2...
And then panels in that region with the hex code used in the game
previous panels that are required to turn them on, as well as the symbols they require:
0x##### (Panel Name) - Required Panels - Required Items
On __init__, the base logic is read and all panels are given Location IDs.
When the world has parsed its options, a second function is called to finalize the logic.
"""
import copy
from collections import defaultdict
from typing import cast, TYPE_CHECKING
from logging import warning
from .static_logic import StaticWitnessLogic, DoorItemDefinition, ItemCategory, ProgressiveItemDefinition
from .utils import *
if TYPE_CHECKING:
from . import WitnessWorld
class WitnessPlayerLogic:
"""WITNESS LOGIC CLASS"""
@lru_cache(maxsize=None)
def reduce_req_within_region(self, panel_hex: str) -> FrozenSet[FrozenSet[str]]:
"""
Panels in this game often only turn on when other panels are solved.
Those other panels may have different item requirements.
It would be slow to recursively check solvability each time.
This is why we reduce the item dependencies within the region.
Panels outside of the same region will still be checked manually.
"""
if panel_hex in self.COMPLETELY_DISABLED_ENTITIES or panel_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES:
return frozenset()
entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel_hex]
these_items = frozenset({frozenset()})
if entity_obj["id"]:
these_items = self.DEPENDENT_REQUIREMENTS_BY_HEX[panel_hex]["items"]
these_items = frozenset({
subset.intersection(self.THEORETICAL_ITEMS_NO_MULTI)
for subset in these_items
})
for subset in these_items:
self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI.update(subset)
these_panels = self.DEPENDENT_REQUIREMENTS_BY_HEX[panel_hex]["panels"]
if panel_hex in self.DOOR_ITEMS_BY_ID:
door_items = frozenset({frozenset([item]) for item in self.DOOR_ITEMS_BY_ID[panel_hex]})
all_options = set()
for dependentItem in door_items:
self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI.update(dependentItem)
for items_option in these_items:
all_options.add(items_option.union(dependentItem))
# 0x28A0D depends on another entity for *non-power* reasons -> This dependency needs to be preserved,
# except in Expert, where that dependency doesn't exist, but now there *is* a power dependency.
# In the future, it would be wise to make a distinction between "power dependencies" and other dependencies.
if panel_hex == "0x28A0D" and not any("0x28998" in option for option in these_panels):
these_items = all_options
# Another dependency that is not power-based: The Symmetry Island Upper Panel latches
elif panel_hex == 0x18269:
these_items = all_options
# For any other door entity, we just return a set with the item that opens it & disregard power dependencies
else:
return frozenset(all_options)
disabled_eps = {eHex for eHex in self.COMPLETELY_DISABLED_ENTITIES
if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[eHex]["entityType"] == "EP"}
these_panels = frozenset({panels - disabled_eps
for panels in these_panels})
if these_panels == frozenset({frozenset()}):
return these_items
all_options = set()
for option in these_panels:
dependent_items_for_option = frozenset({frozenset()})
for option_entity in option:
dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity)
if option_entity in self.EVENT_NAMES_BY_HEX:
new_items = frozenset({frozenset([option_entity])})
elif option_entity in {"7 Lasers", "11 Lasers", "PP2 Weirdness", "Theater to Tunnels"}:
new_items = frozenset({frozenset([option_entity])})
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
)
dependent_items_for_option = dnf_and([dependent_items_for_option, new_items])
for items_option in these_items:
for dependentItem in dependent_items_for_option:
all_options.add(items_option.union(dependentItem))
return dnf_remove_redundancies(frozenset(all_options))
def make_single_adjustment(self, adj_type: str, line: str):
from . import StaticWitnessItems
"""Makes a single logic adjustment based on additional logic file"""
if adj_type == "Items":
line_split = line.split(" - ")
item_name = line_split[0]
if item_name not in StaticWitnessItems.item_data:
raise RuntimeError("Item \"" + item_name + "\" does not exist.")
self.THEORETICAL_ITEMS.add(item_name)
if isinstance(StaticWitnessLogic.all_items[item_name], ProgressiveItemDefinition):
self.THEORETICAL_ITEMS_NO_MULTI.update(cast(ProgressiveItemDefinition,
StaticWitnessLogic.all_items[item_name]).child_item_names)
else:
self.THEORETICAL_ITEMS_NO_MULTI.add(item_name)
if StaticWitnessLogic.all_items[item_name].category in [ItemCategory.DOOR, ItemCategory.LASER]:
panel_hexes = cast(DoorItemDefinition, StaticWitnessLogic.all_items[item_name]).panel_id_hexes
for panel_hex in panel_hexes:
self.DOOR_ITEMS_BY_ID.setdefault(panel_hex, []).append(item_name)
return
if adj_type == "Remove Items":
item_name = line
self.THEORETICAL_ITEMS.discard(item_name)
if isinstance(StaticWitnessLogic.all_items[item_name], ProgressiveItemDefinition):
self.THEORETICAL_ITEMS_NO_MULTI.difference_update(
cast(ProgressiveItemDefinition, StaticWitnessLogic.all_items[item_name]).child_item_names
)
else:
self.THEORETICAL_ITEMS_NO_MULTI.discard(item_name)
if StaticWitnessLogic.all_items[item_name].category in [ItemCategory.DOOR, ItemCategory.LASER]:
panel_hexes = cast(DoorItemDefinition, StaticWitnessLogic.all_items[item_name]).panel_id_hexes
for panel_hex in panel_hexes:
if panel_hex in self.DOOR_ITEMS_BY_ID and item_name in self.DOOR_ITEMS_BY_ID[panel_hex]:
self.DOOR_ITEMS_BY_ID[panel_hex].remove(item_name)
if adj_type == "Starting Inventory":
self.STARTING_INVENTORY.add(line)
if adj_type == "Event Items":
line_split = line.split(" - ")
new_event_name = line_split[0]
hex_set = line_split[1].split(",")
for entity, event_name in self.EVENT_NAMES_BY_HEX.items():
if event_name == new_event_name:
self.DONT_MAKE_EVENTS.add(entity)
for hex_code in hex_set:
self.EVENT_NAMES_BY_HEX[hex_code] = new_event_name
return
if adj_type == "Requirement Changes":
line_split = line.split(" - ")
requirement = {
"panels": parse_lambda(line_split[1]),
}
if len(line_split) > 2:
required_items = parse_lambda(line_split[2])
items_actually_in_the_game = [
item_name for item_name, item_definition in StaticWitnessLogic.all_items.items()
if item_definition.category is ItemCategory.SYMBOL
]
required_items = frozenset(
subset.intersection(items_actually_in_the_game)
for subset in required_items
)
requirement["items"] = required_items
self.DEPENDENT_REQUIREMENTS_BY_HEX[line_split[0]] = requirement
return
if adj_type == "Disabled Locations":
panel_hex = line[:7]
self.COMPLETELY_DISABLED_ENTITIES.add(panel_hex)
return
if adj_type == "Irrelevant Locations":
panel_hex = line[:7]
self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES.add(panel_hex)
return
if adj_type == "Region Changes":
new_region_and_options = define_new_region(line + ":")
self.CONNECTIONS_BY_REGION_NAME[new_region_and_options[0]["name"]] = new_region_and_options[1]
return
if adj_type == "New Connections":
line_split = line.split(" - ")
source_region = line_split[0]
target_region = line_split[1]
panel_set_string = line_split[2]
for connection in self.CONNECTIONS_BY_REGION_NAME[source_region]:
if connection[0] == target_region:
self.CONNECTIONS_BY_REGION_NAME[source_region].remove(connection)
if panel_set_string == "TrueOneWay":
self.CONNECTIONS_BY_REGION_NAME[source_region].add(
(target_region, frozenset({frozenset(["TrueOneWay"])}))
)
else:
new_lambda = connection[1] | parse_lambda(panel_set_string)
self.CONNECTIONS_BY_REGION_NAME[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, parse_lambda(panel_set_string))
self.CONNECTIONS_BY_REGION_NAME[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)
def make_options_adjustments(self, world: "WitnessWorld"):
"""Makes logic adjustments based on options"""
adjustment_linesets_in_order = []
# Postgame
doors = world.options.shuffle_doors >= 2
lasers = world.options.shuffle_lasers
early_caves = world.options.early_caves > 0
victory = world.options.victory_condition
mnt_lasers = world.options.mountain_lasers
chal_lasers = world.options.challenge_lasers
mountain_enterable_from_top = victory == 0 or victory == 1 or (victory == 3 and chal_lasers > mnt_lasers)
if not world.options.shuffle_postgame:
if not (early_caves or doors):
adjustment_linesets_in_order.append(get_caves_exclusion_list())
if not victory == 1:
adjustment_linesets_in_order.append(get_path_to_challenge_exclusion_list())
adjustment_linesets_in_order.append(get_challenge_vault_box_exclusion_list())
adjustment_linesets_in_order.append(get_beyond_challenge_exclusion_list())
if not ((doors or early_caves) and (victory == 0 or (victory == 2 and mnt_lasers > chal_lasers))):
adjustment_linesets_in_order.append(get_beyond_challenge_exclusion_list())
if not victory == 1:
adjustment_linesets_in_order.append(get_challenge_vault_box_exclusion_list())
if not (doors or mountain_enterable_from_top):
adjustment_linesets_in_order.append(get_mountain_lower_exclusion_list())
if not mountain_enterable_from_top:
adjustment_linesets_in_order.append(get_mountain_upper_exclusion_list())
if not ((victory == 0 and doors) or victory == 1 or (victory == 2 and mnt_lasers > chal_lasers and doors)):
if doors:
adjustment_linesets_in_order.append(get_bottom_floor_discard_exclusion_list())
else:
adjustment_linesets_in_order.append(get_bottom_floor_discard_nondoors_exclusion_list())
if victory == 2 and chal_lasers >= mnt_lasers:
adjustment_linesets_in_order.append(["Disabled Locations:", "0xFFF00 (Mountain Box Long)"])
# Exclude Discards / Vaults
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(get_discard_exclusion_list())
if doors:
adjustment_linesets_in_order.append(get_bottom_floor_discard_exclusion_list())
if not world.options.shuffle_vault_boxes:
adjustment_linesets_in_order.append(get_vault_exclusion_list())
if not victory == 1:
adjustment_linesets_in_order.append(get_challenge_vault_box_exclusion_list())
# Victory Condition
if victory == 0:
self.VICTORY_LOCATION = "0x3D9A9"
elif victory == 1:
self.VICTORY_LOCATION = "0x0356B"
elif victory == 2:
self.VICTORY_LOCATION = "0x09F7F"
elif victory == 3:
self.VICTORY_LOCATION = "0xFFF00"
if chal_lasers <= 7:
adjustment_linesets_in_order.append([
"Requirement Changes:",
"0xFFF00 - 11 Lasers - True",
])
if world.options.disable_non_randomized_puzzles:
adjustment_linesets_in_order.append(get_disable_unrandomized_list())
if world.options.shuffle_symbols:
adjustment_linesets_in_order.append(get_symbol_shuffle_list())
if world.options.EP_difficulty == 0:
adjustment_linesets_in_order.append(get_ep_easy())
elif world.options.EP_difficulty == 1:
adjustment_linesets_in_order.append(get_ep_no_eclipse())
if world.options.door_groupings == 1:
if world.options.shuffle_doors == 1:
adjustment_linesets_in_order.append(get_simple_panels())
elif world.options.shuffle_doors == 2:
adjustment_linesets_in_order.append(get_simple_doors())
elif world.options.shuffle_doors == 3:
adjustment_linesets_in_order.append(get_simple_doors())
adjustment_linesets_in_order.append(get_simple_additional_panels())
else:
if world.options.shuffle_doors == 1:
adjustment_linesets_in_order.append(get_complex_door_panels())
adjustment_linesets_in_order.append(get_complex_additional_panels())
elif world.options.shuffle_doors == 2:
adjustment_linesets_in_order.append(get_complex_doors())
elif world.options.shuffle_doors == 3:
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(get_boat())
if world.options.early_caves == 2:
adjustment_linesets_in_order.append(get_early_caves_start_list())
if world.options.early_caves == 1 and not doors:
adjustment_linesets_in_order.append(get_early_caves_list())
if world.options.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(get_laser_shuffle())
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()
if ep_obj["entityType"] == "EP")
for ep_hex, ep_obj in ep_gen:
obelisk = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[self.REFERENCE_LOGIC.EP_TO_OBELISK_SIDE[ep_hex]]
obelisk_name = obelisk["checkName"]
ep_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[ep_hex]["checkName"]
self.EVENT_NAMES_BY_HEX[ep_hex] = f"{obelisk_name} - {ep_name}"
else:
adjustment_linesets_in_order.append(["Disabled Locations:"] + get_ep_obelisks()[1:])
if world.options.shuffle_EPs == 0:
adjustment_linesets_in_order.append(["Irrelevant Locations:"] + get_ep_all_individual()[1:])
yaml_disabled_eps = []
for yaml_disabled_location in self.YAML_DISABLED_LOCATIONS:
if yaml_disabled_location not in self.REFERENCE_LOGIC.ENTITIES_BY_NAME:
continue
loc_obj = self.REFERENCE_LOGIC.ENTITIES_BY_NAME[yaml_disabled_location]
if loc_obj["entityType"] == "EP" and world.options.shuffle_EPs != 0:
yaml_disabled_eps.append(loc_obj["entity_hex"])
if loc_obj["entityType"] in {"EP", "General", "Vault", "Discard"}:
self.EXCLUDED_LOCATIONS.add(loc_obj["entity_hex"])
adjustment_linesets_in_order.append(["Disabled Locations:"] + yaml_disabled_eps)
for adjustment_lineset in adjustment_linesets_in_order:
current_adjustment_type = None
for line in adjustment_lineset:
if len(line) == 0:
continue
if line[-1] == ":":
current_adjustment_type = line[:-1]
continue
self.make_single_adjustment(current_adjustment_type, line)
for entity_id in self.COMPLETELY_DISABLED_ENTITIES:
if entity_id in self.DOOR_ITEMS_BY_ID:
del self.DOOR_ITEMS_BY_ID[entity_id]
def make_dependency_reduced_checklist(self):
"""
Turns dependent check set into semi-independent check set
"""
for entity_hex in self.DEPENDENT_REQUIREMENTS_BY_HEX.keys():
indep_requirement = self.reduce_req_within_region(entity_hex)
self.REQUIREMENTS_BY_HEX[entity_hex] = indep_requirement
for item in self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI:
if item not in self.THEORETICAL_ITEMS:
progressive_item_name = StaticWitnessLogic.get_parent_progressive_item(item)
self.PROG_ITEMS_ACTUALLY_IN_THE_GAME.add(progressive_item_name)
child_items = cast(ProgressiveItemDefinition,
StaticWitnessLogic.all_items[progressive_item_name]).child_item_names
multi_list = [child_item for child_item in child_items
if child_item in self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI]
self.MULTI_AMOUNTS[item] = multi_list.index(item) + 1
self.MULTI_LISTS[progressive_item_name] = multi_list
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.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 = dnf_and([entity_req, frozenset({frozenset({region_name})})])
individual_entity_requirements.append(entity_req)
overall_requirement |= dnf_and(individual_entity_requirements)
new_connections.append((connection[0], overall_requirement))
self.CONNECTIONS_BY_REGION_NAME[region] = new_connections
def make_event_item_pair(self, panel: 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"
name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel]["checkName"] + action
if panel not in self.EVENT_NAMES_BY_HEX:
warning("Panel \"" + name + "\" does not have an associated event name.")
self.EVENT_NAMES_BY_HEX[panel] = name + " Event"
pair = (name, self.EVENT_NAMES_BY_HEX[panel])
return pair
def make_event_panel_lists(self):
self.EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory"
for event_hex, event_name in self.EVENT_NAMES_BY_HEX.items():
if event_hex in self.COMPLETELY_DISABLED_ENTITIES or event_hex in self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES:
continue
self.EVENT_PANELS.add(event_hex)
for panel in self.EVENT_PANELS:
pair = self.make_event_item_pair(panel)
self.EVENT_ITEM_PAIRS[pair[0]] = pair[1]
def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]):
self.YAML_DISABLED_LOCATIONS = disabled_locations
self.YAML_ADDED_ITEMS = start_inv
self.EVENT_PANELS_FROM_PANELS = set()
self.EVENT_PANELS_FROM_REGIONS = set()
self.IRRELEVANT_BUT_NOT_DISABLED_ENTITIES = set()
self.THEORETICAL_ITEMS = set()
self.THEORETICAL_ITEMS_NO_MULTI = set()
self.MULTI_AMOUNTS = defaultdict(lambda: 1)
self.MULTI_LISTS = dict()
self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set()
self.PROG_ITEMS_ACTUALLY_IN_THE_GAME = set()
self.DOOR_ITEMS_BY_ID: Dict[str, List[str]] = {}
self.STARTING_INVENTORY = set()
self.DIFFICULTY = world.options.puzzle_randomization.value
if self.DIFFICULTY == 0:
self.REFERENCE_LOGIC = StaticWitnessLogic.sigma_normal
elif self.DIFFICULTY == 1:
self.REFERENCE_LOGIC = StaticWitnessLogic.sigma_expert
elif self.DIFFICULTY == 2:
self.REFERENCE_LOGIC = StaticWitnessLogic.vanilla
self.CONNECTIONS_BY_REGION_NAME = copy.copy(self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME)
self.DEPENDENT_REQUIREMENTS_BY_HEX = copy.copy(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_PANELS = set()
self.EVENT_ITEM_PAIRS = dict()
self.DONT_MAKE_EVENTS = set()
self.COMPLETELY_DISABLED_ENTITIES = set()
self.PRECOMPLETED_LOCATIONS = set()
self.EXCLUDED_LOCATIONS = set()
self.ADDED_CHECKS = set()
self.VICTORY_LOCATION = "0x0356B"
self.EVENT_NAMES_BY_HEX = {
"0x00509": "+1 Laser (Symmetry Laser)",
"0x012FB": "+1 Laser (Desert Laser)",
"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)",
"0x09F7F": "Mountain Entry",
"0xFFF00": "Bottom Floor Discard Turns On",
}
self.make_options_adjustments(world)
self.make_dependency_reduced_checklist()
self.make_event_panel_lists()