| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  | from collections import defaultdict | 
					
						
							| 
									
										
										
										
											2024-12-12 19:42:14 +01:00
										 |  |  | from logging import debug, warning | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  | 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() | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-12-12 19:42:14 +01:00
										 |  |  |         self._add_plandoed_hunt_panels_to_pre_picked() | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |         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 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-12-12 19:42:14 +01:00
										 |  |  |     def _entity_is_eligible(self, panel_hex: str, plando: bool = False) -> bool: | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |         """
 | 
					
						
							|  |  |  |         Determine whether an entity is eligible for entity hunt based on player options. | 
					
						
							|  |  |  |         """
 | 
					
						
							|  |  |  |         panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex] | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-12-12 19:42:14 +01:00
										 |  |  |         if not self.player_logic.solvability_guaranteed(panel_hex) or panel_hex in self.player_logic.EXCLUDED_ENTITIES: | 
					
						
							|  |  |  |             if plando: | 
					
						
							|  |  |  |                 warning(f"Panel {panel_obj['checkName']} is disabled / excluded and thus not eligible for panel hunt.") | 
					
						
							|  |  |  |             return False | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return plando or 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" | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |         ) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-12-12 19:42:14 +01:00
										 |  |  |     def _add_plandoed_hunt_panels_to_pre_picked(self) -> None: | 
					
						
							|  |  |  |         """
 | 
					
						
							|  |  |  |         Add panels the player explicitly specified to be included in panel hunt to the pre picked hunt panels. | 
					
						
							|  |  |  |         Output a warning if a panel could not be added for some reason. | 
					
						
							|  |  |  |         """
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         # Plandoed hunt panels should be in random order, but deterministic by seed, so we sort, then shuffle | 
					
						
							|  |  |  |         panels_to_plando = sorted(self.player_options.panel_hunt_plando.value) | 
					
						
							|  |  |  |         self.random.shuffle(panels_to_plando) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         for location_name in panels_to_plando: | 
					
						
							|  |  |  |             entity_hex = static_witness_logic.ENTITIES_BY_NAME[location_name]["entity_hex"] | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if entity_hex in self.PRE_PICKED_HUNT_ENTITIES: | 
					
						
							|  |  |  |                 continue | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if self._entity_is_eligible(entity_hex, plando=True): | 
					
						
							|  |  |  |                 if len(self.PRE_PICKED_HUNT_ENTITIES) == self.player_options.panel_hunt_total: | 
					
						
							|  |  |  |                     warning( | 
					
						
							|  |  |  |                         f"Panel {location_name} could not be plandoed as a hunt panel for {self.player_name}'s world, " | 
					
						
							|  |  |  |                         f"because it would exceed their panel hunt total." | 
					
						
							|  |  |  |                     ) | 
					
						
							|  |  |  |                     continue | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |                 self.PRE_PICKED_HUNT_ENTITIES.add(entity_hex) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |     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: | 
					
						
							| 
									
										
										
										
											2025-03-13 23:59:09 +01:00
										 |  |  |             associated_area = static_witness_logic.ENTITIES_BY_HEX[eligible_panel]["area"].name | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |             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(): | 
					
						
							| 
									
										
										
										
											2024-09-06 18:23:16 +01:00
										 |  |  |             for panel in sorted(eligible_entities - self.HUNT_ENTITIES): | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |                 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 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-12-12 19:42:14 +01:00
										 |  |  |             # ... and it's not a forced pick that should stay the same ... | 
					
						
							|  |  |  |             if bad_entitiy in self.PRE_PICKED_HUNT_ENTITIES: | 
					
						
							|  |  |  |                 continue | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-20 01:16:35 +02:00
										 |  |  |             # ... 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)}" | 
					
						
							|  |  |  |         ) |