236 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			236 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Python
		
	
	
	
from collections import defaultdict
 | 
						|
from logging import debug
 | 
						|
from pprint import pformat
 | 
						|
from typing import TYPE_CHECKING, Dict, List, Set, Tuple
 | 
						|
 | 
						|
from .data import static_logic as static_witness_logic
 | 
						|
 | 
						|
if TYPE_CHECKING:
 | 
						|
    from . import WitnessWorld
 | 
						|
    from .player_logic import WitnessPlayerLogic
 | 
						|
 | 
						|
DISALLOWED_ENTITIES_FOR_PANEL_HUNT = {
 | 
						|
    "0x03629",  # Tutorial Gate Open, which is the panel that is locked by panel hunt
 | 
						|
    "0x03505",  # Tutorial Gate Close (same thing)
 | 
						|
    "0x3352F",  # Gate EP (same thing)
 | 
						|
    "0x09F7F",  # Mountaintop Box Short. This is reserved for panel_hunt_postgame.
 | 
						|
    "0x00CDB",  # Challenge Reallocating
 | 
						|
    "0x0051F",  # Challenge Reallocating
 | 
						|
    "0x00524",  # Challenge Reallocating
 | 
						|
    "0x00CD4",  # Challenge Reallocating
 | 
						|
    "0x00CB9",  # Challenge May Be Unsolvable
 | 
						|
    "0x00CA1",  # Challenge May Be Unsolvable
 | 
						|
    "0x00C80",  # Challenge May Be Unsolvable
 | 
						|
    "0x00C68",  # Challenge May Be Unsolvable
 | 
						|
    "0x00C59",  # Challenge May Be Unsolvable
 | 
						|
    "0x00C22",  # Challenge May Be Unsolvable
 | 
						|
    "0x0A3A8",  # Reset PP
 | 
						|
    "0x0A3B9",  # Reset PP
 | 
						|
    "0x0A3BB",  # Reset PP
 | 
						|
    "0x0A3AD",  # Reset PP
 | 
						|
}
 | 
						|
 | 
						|
ALL_HUNTABLE_PANELS = [
 | 
						|
    entity_hex
 | 
						|
    for entity_hex, entity_obj in static_witness_logic.ENTITIES_BY_HEX.items()
 | 
						|
    if entity_obj["entityType"] == "Panel" and entity_hex not in DISALLOWED_ENTITIES_FOR_PANEL_HUNT
 | 
						|
]
 | 
						|
 | 
						|
 | 
						|
class EntityHuntPicker:
 | 
						|
    def __init__(self, player_logic: "WitnessPlayerLogic", world: "WitnessWorld",
 | 
						|
                 pre_picked_entities: Set[str]) -> None:
 | 
						|
        self.player_logic = player_logic
 | 
						|
        self.player_options = world.options
 | 
						|
        self.player_name = world.player_name
 | 
						|
        self.random = world.random
 | 
						|
 | 
						|
        self.PRE_PICKED_HUNT_ENTITIES = pre_picked_entities.copy()
 | 
						|
        self.HUNT_ENTITIES: Set[str] = set()
 | 
						|
 | 
						|
        self.ALL_ELIGIBLE_ENTITIES, self.ELIGIBLE_ENTITIES_PER_AREA = self._get_eligible_panels()
 | 
						|
 | 
						|
    def pick_panel_hunt_panels(self, total_amount: int) -> Set[str]:
 | 
						|
        """
 | 
						|
        The process of picking all hunt entities is:
 | 
						|
 | 
						|
        1. Add pre-defined hunt entities
 | 
						|
        2. Pick random hunt entities to fill out the rest
 | 
						|
        3. Replace unfair entities with fair entities
 | 
						|
 | 
						|
        Each of these is its own function.
 | 
						|
        """
 | 
						|
 | 
						|
        self.HUNT_ENTITIES = self.PRE_PICKED_HUNT_ENTITIES.copy()
 | 
						|
 | 
						|
        self._pick_all_hunt_entities(total_amount)
 | 
						|
        self._replace_unfair_hunt_entities_with_good_hunt_entities()
 | 
						|
        self._log_results()
 | 
						|
 | 
						|
        return self.HUNT_ENTITIES
 | 
						|
 | 
						|
    def _entity_is_eligible(self, panel_hex: str) -> bool:
 | 
						|
        """
 | 
						|
        Determine whether an entity is eligible for entity hunt based on player options.
 | 
						|
        """
 | 
						|
        panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex]
 | 
						|
 | 
						|
        return (
 | 
						|
            self.player_logic.solvability_guaranteed(panel_hex)
 | 
						|
            and panel_hex not in self.player_logic.EXCLUDED_ENTITIES
 | 
						|
            and not (
 | 
						|
                # Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off.
 | 
						|
                # However, I don't think they should be hunt panels in this case.
 | 
						|
                self.player_options.disable_non_randomized_puzzles
 | 
						|
                and not self.player_options.shuffle_discarded_panels
 | 
						|
                and panel_obj["locationType"] == "Discard"
 | 
						|
            )
 | 
						|
        )
 | 
						|
 | 
						|
    def _get_eligible_panels(self) -> Tuple[List[str], Dict[str, Set[str]]]:
 | 
						|
        """
 | 
						|
        There are some entities that are not allowed for panel hunt for various technical of gameplay reasons.
 | 
						|
        Make a list of all the ones that *are* eligible, plus a lookup of eligible panels per area.
 | 
						|
        """
 | 
						|
 | 
						|
        all_eligible_panels = [
 | 
						|
            panel for panel in ALL_HUNTABLE_PANELS
 | 
						|
            if self._entity_is_eligible(panel)
 | 
						|
        ]
 | 
						|
 | 
						|
        eligible_panels_by_area = defaultdict(set)
 | 
						|
        for eligible_panel in all_eligible_panels:
 | 
						|
            associated_area = static_witness_logic.ENTITIES_BY_HEX[eligible_panel]["area"]["name"]
 | 
						|
            eligible_panels_by_area[associated_area].add(eligible_panel)
 | 
						|
 | 
						|
        return all_eligible_panels, eligible_panels_by_area
 | 
						|
 | 
						|
    def _get_percentage_of_hunt_entities_by_area(self) -> Dict[str, float]:
 | 
						|
        hunt_entities_picked_so_far_prevent_div_0 = max(len(self.HUNT_ENTITIES), 1)
 | 
						|
 | 
						|
        contributing_percentage_per_area = {}
 | 
						|
        for area, eligible_entities in self.ELIGIBLE_ENTITIES_PER_AREA.items():
 | 
						|
            amount_of_already_chosen_entities = len(self.ELIGIBLE_ENTITIES_PER_AREA[area] & self.HUNT_ENTITIES)
 | 
						|
            current_percentage = amount_of_already_chosen_entities / hunt_entities_picked_so_far_prevent_div_0
 | 
						|
            contributing_percentage_per_area[area] = current_percentage
 | 
						|
 | 
						|
        return contributing_percentage_per_area
 | 
						|
 | 
						|
    def _get_next_random_batch(self, amount: int, same_area_discouragement: float) -> List[str]:
 | 
						|
        """
 | 
						|
        Pick the next batch of hunt entities.
 | 
						|
        Areas that already have a lot of hunt entities in them will be discouraged from getting more.
 | 
						|
        The strength of this effect is controlled by the same_area_discouragement factor from the player's options.
 | 
						|
        """
 | 
						|
 | 
						|
        percentage_of_hunt_entities_by_area = self._get_percentage_of_hunt_entities_by_area()
 | 
						|
 | 
						|
        max_percentage = max(percentage_of_hunt_entities_by_area.values())
 | 
						|
        if max_percentage == 0:
 | 
						|
            allowance_per_area = {area: 1.0 for area in percentage_of_hunt_entities_by_area}
 | 
						|
        else:
 | 
						|
            allowance_per_area = {
 | 
						|
                area: (max_percentage - current_percentage) / max_percentage
 | 
						|
                for area, current_percentage in percentage_of_hunt_entities_by_area.items()
 | 
						|
            }
 | 
						|
            # use same_area_discouragement as lerp factor
 | 
						|
            allowance_per_area = {
 | 
						|
                area: (1.0 - same_area_discouragement) + (weight * same_area_discouragement)
 | 
						|
                for area, weight in allowance_per_area.items()
 | 
						|
            }
 | 
						|
 | 
						|
        assert min(allowance_per_area.values()) >= 0, (
 | 
						|
            f"Somehow, an area had a negative weight when picking hunt entities: {allowance_per_area}"
 | 
						|
        )
 | 
						|
 | 
						|
        remaining_entities, remaining_entity_weights = [], []
 | 
						|
        for area, eligible_entities in self.ELIGIBLE_ENTITIES_PER_AREA.items():
 | 
						|
            for panel in sorted(eligible_entities - self.HUNT_ENTITIES):
 | 
						|
                remaining_entities.append(panel)
 | 
						|
                remaining_entity_weights.append(allowance_per_area[area])
 | 
						|
 | 
						|
        # I don't think this can ever happen, but let's be safe
 | 
						|
        if sum(remaining_entity_weights) == 0:
 | 
						|
            remaining_entity_weights = [1] * len(remaining_entity_weights)
 | 
						|
 | 
						|
        return self.random.choices(remaining_entities, weights=remaining_entity_weights, k=amount)
 | 
						|
 | 
						|
    def _pick_all_hunt_entities(self, total_amount: int) -> None:
 | 
						|
        """
 | 
						|
        The core function of the EntityHuntPicker in which all Hunt Entities are picked,
 | 
						|
        respecting the player's choices for total amount and same area discouragement.
 | 
						|
        """
 | 
						|
        same_area_discouragement = self.player_options.panel_hunt_discourage_same_area_factor / 100
 | 
						|
 | 
						|
        # If we're using random picking, just choose all the entities now and return
 | 
						|
        if not same_area_discouragement:
 | 
						|
            hunt_entities = self.random.sample(
 | 
						|
                [entity for entity in self.ALL_ELIGIBLE_ENTITIES if entity not in self.HUNT_ENTITIES],
 | 
						|
                k=total_amount - len(self.HUNT_ENTITIES),
 | 
						|
            )
 | 
						|
            self.HUNT_ENTITIES.update(hunt_entities)
 | 
						|
            return
 | 
						|
 | 
						|
        # If we're discouraging entities from the same area being picked, we have to pick entities one at a time
 | 
						|
        # For higher total counts, we do them in small batches for performance
 | 
						|
        batch_size = max(1, total_amount // 20)
 | 
						|
 | 
						|
        while len(self.HUNT_ENTITIES) < total_amount:
 | 
						|
            actual_amount_to_pick = min(batch_size, total_amount - len(self.HUNT_ENTITIES))
 | 
						|
 | 
						|
            self.HUNT_ENTITIES.update(self._get_next_random_batch(actual_amount_to_pick, same_area_discouragement))
 | 
						|
 | 
						|
    def _replace_unfair_hunt_entities_with_good_hunt_entities(self) -> None:
 | 
						|
        """
 | 
						|
        For connected entities that "solve together", make sure that the one you're guaranteed
 | 
						|
        to be able to see and interact with first is the one that is chosen, so you don't get "surprise entities".
 | 
						|
        """
 | 
						|
 | 
						|
        replacements = {
 | 
						|
            "0x18488": "0x00609",  # Replace Swamp Sliding Bridge Underwater -> Swamp Sliding Bridge Above Water
 | 
						|
            "0x03676": "0x03678",  # Replace Quarry Upper Ramp Control -> Lower Ramp Control
 | 
						|
            "0x03675": "0x03679",  # Replace Quarry Upper Lift Control -> Lower Lift Control
 | 
						|
 | 
						|
            "0x03702": "0x15ADD",  # Jungle Vault Box -> Jungle Vault Panel
 | 
						|
            "0x03542": "0x002A6",  # Mountainside Vault Box -> Mountainside Vault Panel
 | 
						|
            "0x03481": "0x033D4",  # Tutorial Vault Box -> Tutorial Vault Panel
 | 
						|
            "0x0339E": "0x0CC7B",  # Desert Vault Box -> Desert Vault Panel
 | 
						|
            "0x03535": "0x00AFB",  # Shipwreck Vault Box -> Shipwreck Vault Panel
 | 
						|
        }
 | 
						|
 | 
						|
        if self.player_options.shuffle_doors < 2:
 | 
						|
            replacements.update(
 | 
						|
                {
 | 
						|
                    "0x334DC": "0x334DB",  # In door shuffle, the Shadows Timer Panels are disconnected
 | 
						|
                    "0x17CBC": "0x2700B",  # In door shuffle, the Laser Timer Panels are disconnected
 | 
						|
                }
 | 
						|
            )
 | 
						|
 | 
						|
        for bad_entitiy, good_entity in replacements.items():
 | 
						|
            # If the bad entity was picked as a hunt entity ...
 | 
						|
            if bad_entitiy not in self.HUNT_ENTITIES:
 | 
						|
                continue
 | 
						|
 | 
						|
            # ... and the good entity was not ...
 | 
						|
            if good_entity in self.HUNT_ENTITIES or good_entity not in self.ALL_ELIGIBLE_ENTITIES:
 | 
						|
                continue
 | 
						|
 | 
						|
            # ... replace the bad entity with the good entity.
 | 
						|
            self.HUNT_ENTITIES.remove(bad_entitiy)
 | 
						|
            self.HUNT_ENTITIES.add(good_entity)
 | 
						|
 | 
						|
    def _log_results(self) -> None:
 | 
						|
        final_percentage_by_area = self._get_percentage_of_hunt_entities_by_area()
 | 
						|
 | 
						|
        sorted_area_percentages_dict = dict(sorted(final_percentage_by_area.items(), key=lambda x: x[1]))
 | 
						|
        sorted_area_percentages_dict_pretty_print = {
 | 
						|
            area: str(percentage) + (" (maxed)" if self.ELIGIBLE_ENTITIES_PER_AREA[area] <= self.HUNT_ENTITIES else "")
 | 
						|
            for area, percentage in sorted_area_percentages_dict.items()
 | 
						|
        }
 | 
						|
        player_name = self.player_name
 | 
						|
        discouragemenet_factor = self.player_options.panel_hunt_discourage_same_area_factor
 | 
						|
        debug(
 | 
						|
            f'Final area percentages for player "{player_name}" ({discouragemenet_factor} discouragement):\n'
 | 
						|
            f"{pformat(sorted_area_percentages_dict_pretty_print)}"
 | 
						|
        )
 |