269 lines
12 KiB
Python
269 lines
12 KiB
Python
from collections import defaultdict
|
|
from logging import debug, warning
|
|
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._add_plandoed_hunt_panels_to_pre_picked()
|
|
|
|
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, plando: bool = False) -> bool:
|
|
"""
|
|
Determine whether an entity is eligible for entity hunt based on player options.
|
|
"""
|
|
panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex]
|
|
|
|
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"
|
|
)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
# ... and it's not a forced pick that should stay the same ...
|
|
if bad_entitiy in self.PRE_PICKED_HUNT_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)}"
|
|
)
|