The Witness: Panel Hunt Mode (#3265)

* Add panel hunt options

* Make sure all panels are either solvable or disabled in panel hunt

* Pick huntable panels

* Discards in disable non randomized

* Set up panel hunt requirement

* Panel hunt functional

* Make it so an event can have multiple names

* Panel hunt with events

* Add hunt entities to slot data

* ruff

* add to hint data, no client sneding yet

* encode panel hunt amount in compact hint data

* Remove print statement

* my b

* consistent

* meh

* additions for lcient

* Nah

* Victory panels ineligible for panel hunt

* Panel Hunt Postgame option

* cleanup

* Add data generation file

* pull out set

* always disable gate ep in panel hunt

* Disallow certain challenge panels from being panel hunt panels

* Make panelhuntpostgame its own function, so it can be called even if normal postgame is enabled

* disallow PP resets from panel hunt

* Disable challenge timer and elevetor start respectively in disable hunt postgame

* Fix panelhunt postgame

* lol

* When you test that the bug is fixed but not that the non-bug is not unfixed

* Prevent Obelisks from being panel hunt panels

* Make picking panels for panel hunt a bit more sophisticated, if less random

* Better function maybe ig

* Ok maybe that was a bit too much

* Give advanced players some control over panel hunt

* lint

* correct the logic for amount to pick

* decided the jingle thing was dumb, I'll figure sth out client side. Same area discouragement is now a configurable factor, and the logic has been significantly rewritten

* comment

* Make the option visible

* Safety

* Change assert slightly

* We do a little logging

* number tweak & we do a lil logging

* we do a little more logging

* Ruff

* Panel Hunt Option Group

* Idk how that got here

* Update worlds/witness/options.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update worlds/witness/__init__.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* remove merge error

* Update worlds/witness/player_logic.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* True

* Don't have underwater sliding bridge when you have above water sliding bridge

* These are not actually connected lol

* get rid of unnecessary variable

* Refactor compact hint function again

* lint

* Pull out Entity Hunt Picking into its own class, split it into many functions. Kept a lot of the comments tho

* forgot to actually add the new file

* some more refactoring & docstrings

* consistent naming

* flip elif change

* Comment about naming

* Make static eligible panels a constant I can refer back to

* slight formatting change

* pull out options-based eligibility into its own function

* better text and stuff

* lint

* this is not necessary

* capitalisation

* Fix same area discouragement 0

* Simplify data file generation

* Simplify data file generation

* prevent div 0

* Add Vault Boxes -> Vault Panels to replacements

* Update options.py

* Update worlds/witness/entity_hunt.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update entity_hunt.py

* Fix some events not working

* assert

* remove now unused function

* lint

* Lasers Activate, Lasers don't Solve

* lint

* oops

* mypy

* lint

* Add simple panel hunt unit test

* Add Panel Hunt Tests

* Add more Panel Hunt Tests

* Disallow Box Short for normal panel hunt

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
This commit is contained in:
NewSoupVi 2024-08-20 01:16:35 +02:00 committed by GitHub
parent c010c8c938
commit f253dffc07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 751 additions and 117 deletions

View File

@ -15,7 +15,7 @@ from .data import static_locations as static_witness_locations
from .data import static_logic as static_witness_logic
from .data.item_definition_classes import DoorItemDefinition, ItemData
from .data.utils import get_audio_logs
from .hints import CompactItemData, create_all_hints, make_compact_hint_data, make_laser_hints
from .hints import CompactHintData, create_all_hints, make_compact_hint_data, make_laser_hints
from .locations import WitnessPlayerLocations
from .options import TheWitnessOptions, witness_option_groups
from .player_items import WitnessItem, WitnessPlayerItems
@ -68,12 +68,14 @@ class WitnessWorld(World):
player_items: WitnessPlayerItems
player_regions: WitnessPlayerRegions
log_ids_to_hints: Dict[int, CompactItemData]
laser_ids_to_hints: Dict[int, CompactItemData]
log_ids_to_hints: Dict[int, CompactHintData]
laser_ids_to_hints: Dict[int, CompactHintData]
items_placed_early: List[str]
own_itempool: List[WitnessItem]
panel_hunt_required_count: int
def _get_slot_data(self) -> Dict[str, Any]:
return {
"seed": self.random.randrange(0, 1000000),
@ -83,12 +85,14 @@ class WitnessWorld(World):
"door_hexes_in_the_pool": self.player_items.get_door_ids_in_pool(),
"symbols_not_in_the_game": self.player_items.get_symbol_ids_not_in_pool(),
"disabled_entities": [int(h, 16) for h in self.player_logic.COMPLETELY_DISABLED_ENTITIES],
"hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES],
"log_ids_to_hints": self.log_ids_to_hints,
"laser_ids_to_hints": self.laser_ids_to_hints,
"progressive_item_lists": self.player_items.get_progressive_item_ids_in_pool(),
"obelisk_side_id_to_EPs": static_witness_logic.OBELISK_SIDE_ID_TO_EP_HEXES,
"precompleted_puzzles": [int(h, 16) for h in self.player_logic.EXCLUDED_LOCATIONS],
"entity_to_name": static_witness_logic.ENTITY_ID_TO_NAME,
"panel_hunt_required_absolute": self.panel_hunt_required_count
}
def determine_sufficient_progression(self) -> None:
@ -151,6 +155,13 @@ class WitnessWorld(World):
if self.options.shuffle_lasers == "local":
self.options.local_items.value |= self.item_name_groups["Lasers"]
if self.options.victory_condition == "panel_hunt":
total_panels = self.options.panel_hunt_total
required_percentage = self.options.panel_hunt_required_percentage
self.panel_hunt_required_count = round(total_panels * required_percentage / 100)
else:
self.panel_hunt_required_count = 0
def create_regions(self) -> None:
self.player_regions.create_regions(self, self.player_logic)
@ -169,7 +180,7 @@ class WitnessWorld(World):
for event_location in self.player_locations.EVENT_LOCATION_TABLE:
item_obj = self.create_item(
self.player_logic.EVENT_ITEM_PAIRS[event_location]
self.player_logic.EVENT_ITEM_PAIRS[event_location][0]
)
location_obj = self.get_location(event_location)
location_obj.place_locked_item(item_obj)
@ -192,7 +203,7 @@ class WitnessWorld(World):
]
if early_items:
random_early_item = self.random.choice(early_items)
if self.options.puzzle_randomization == "sigma_expert":
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.
self.multiworld.local_early_items[self.player][random_early_item] = 1
else:
@ -305,8 +316,8 @@ class WitnessWorld(World):
self.options.local_items.value.add(item_name)
def fill_slot_data(self) -> Dict[str, Any]:
self.log_ids_to_hints: Dict[int, CompactItemData] = {}
self.laser_ids_to_hints: Dict[int, CompactItemData] = {}
self.log_ids_to_hints: Dict[int, CompactHintData] = {}
self.laser_ids_to_hints: Dict[int, CompactHintData] = {}
already_hinted_locations = set()

View File

@ -0,0 +1,6 @@
Requirement Changes:
0x03629 - Entity Hunt - True
0x03505 - 0x03629 - True
New Connections:
Tutorial - Outside Tutorial - True

View File

@ -406,6 +406,10 @@ GENERAL_LOCATIONS = {
"Mountain Bottom Floor Discard",
}
GENERAL_LOCATION_HEXES = {
static_witness_logic.ENTITIES_BY_NAME[entity_name]["entity_hex"] for entity_name in GENERAL_LOCATIONS
}
OBELISK_SIDES = {
"Desert Obelisk Side 1",
"Desert Obelisk Side 2",

View File

@ -103,6 +103,7 @@ class StaticWitnessLogicObj:
"region": None,
"id": None,
"entityType": location_id,
"locationType": None,
"area": current_area,
}
@ -127,19 +128,30 @@ class StaticWitnessLogicObj:
"Laser Hedges",
"Laser Pressure Plates",
}
is_vault_or_video = "Vault" in entity_name or "Video" in entity_name
if "Discard" in entity_name:
entity_type = "Panel"
location_type = "Discard"
elif is_vault_or_video or entity_name == "Tutorial Gate Close":
elif "Vault" in entity_name:
entity_type = "Panel"
location_type = "Vault"
elif entity_name in laser_names:
location_type = "Laser"
entity_type = "Laser"
location_type = None
elif "Obelisk Side" in entity_name:
entity_type = "Obelisk Side"
location_type = "Obelisk Side"
elif "Obelisk" in entity_name:
entity_type = "Obelisk"
location_type = None
elif "EP" in entity_name:
entity_type = "EP"
location_type = "EP"
elif entity_hex.startswith("0xFF"):
entity_type = "Event"
location_type = None
else:
entity_type = "Panel"
location_type = "General"
required_items = parse_lambda(required_item_lambda)
@ -152,7 +164,7 @@ class StaticWitnessLogicObj:
"items": required_items
}
if location_type == "Obelisk Side":
if entity_type == "Obelisk Side":
eps = set(next(iter(required_panels)))
eps -= {"Theater to Tunnels"}
@ -167,7 +179,8 @@ class StaticWitnessLogicObj:
"entity_hex": entity_hex,
"region": current_region,
"id": int(location_id),
"entityType": location_type,
"entityType": entity_type,
"locationType": location_type,
"area": current_area,
}

View File

@ -203,6 +203,10 @@ def get_elevators_come_to_you() -> List[str]:
return get_adjustment_file("settings/Door_Shuffle/Elevators_Come_To_You.txt")
def get_entity_hunt() -> List[str]:
return get_adjustment_file("settings/Entity_Hunt.txt")
def get_sigma_normal_logic() -> List[str]:
return get_adjustment_file("WitnessLogic.txt")

View File

@ -0,0 +1,234 @@
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 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 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)}"
)

View File

@ -0,0 +1,45 @@
from collections import defaultdict
from data import static_logic as static_witness_logic
if __name__ == "__main__":
with open("data/APWitnessData.h", "w") as datafile:
datafile.write("""# pragma once
# include <map>
# include <set>
# include <string>
""")
area_to_location_ids = defaultdict(list)
area_to_entity_ids = defaultdict(list)
for entity_id, entity_object in static_witness_logic.ENTITIES_BY_HEX.items():
location_id = entity_object["id"]
area = entity_object["area"]["name"]
area_to_entity_ids[area].append(entity_id)
if location_id is None:
continue
area_to_location_ids[area].append(str(location_id))
datafile.write("inline std::map<std::string, std::set<int64_t>> areaNameToLocationIDs = {\n")
datafile.write(
"\n".join(
'\t{"' + area + '", { ' + ", ".join(location_ids) + " }},"
for area, location_ids in area_to_location_ids.items()
)
)
datafile.write("\n};\n\n")
datafile.write("inline std::map<std::string, std::set<int64_t>> areaNameToEntityIDs = {\n")
datafile.write(
"\n".join(
'\t{"' + area + '", { ' + ", ".join(entity_ids) + " }},"
for area, entity_ids in area_to_entity_ids.items()
)
)
datafile.write("\n};\n\n")

View File

@ -11,7 +11,8 @@ from .player_items import WitnessItem
if TYPE_CHECKING:
from . import WitnessWorld
CompactItemData = Tuple[str, Union[str, int], int]
CompactHintArgs = Tuple[Union[str, int], int]
CompactHintData = Tuple[str, Union[str, int], int]
@dataclass
@ -35,6 +36,7 @@ class WitnessWordedHint:
location: Optional[Location] = None
area: Optional[str] = None
area_amount: Optional[int] = None
area_hunt_panels: Optional[int] = None
def get_always_hint_items(world: "WitnessWorld") -> List[str]:
@ -391,22 +393,22 @@ def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]]
return locations_per_area, items_per_area
def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: List[Item]) -> Tuple[str, int]:
def word_area_hint(world: "WitnessWorld", hinted_area: str, area_items: List[Item]) -> Tuple[str, int, Optional[int]]:
"""
Word the hint for an area using natural sounding language.
This takes into account how much progression there is, how much of it is local/non-local, and whether there are
any local lasers to be found in this area.
"""
local_progression = sum(item.player == world.player and item.advancement for item in corresponding_items)
non_local_progression = sum(item.player != world.player and item.advancement for item in corresponding_items)
local_progression = sum(item.player == world.player and item.advancement for item in area_items)
non_local_progression = sum(item.player != world.player and item.advancement for item in area_items)
laser_names = {"Symmetry Laser", "Desert Laser", "Quarry Laser", "Shadows Laser", "Town Laser", "Monastery Laser",
"Jungle Laser", "Bunker Laser", "Swamp Laser", "Treehouse Laser", "Keep Laser", }
local_lasers = sum(
item.player == world.player and item.name in laser_names
for item in corresponding_items
for item in area_items
)
total_progression = non_local_progression + local_progression
@ -415,11 +417,29 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items:
area_progression_word = "Both" if total_progression == 2 else "All"
hint_string = f"In the {hinted_area} area, you will find "
hunt_panels = None
if world.options.victory_condition == "panel_hunt":
hunt_panels = sum(
static_witness_logic.ENTITIES_BY_HEX[hunt_entity]["area"]["name"] == hinted_area
for hunt_entity in world.player_logic.HUNT_ENTITIES
)
if not hunt_panels:
hint_string += "no Hunt Panels and "
elif hunt_panels == 1:
hint_string += "1 Hunt Panel and "
else:
hint_string += f"{hunt_panels} Hunt Panels and "
if not total_progression:
hint_string = f"In the {hinted_area} area, you will find no progression items."
hint_string += "no progression items."
elif total_progression == 1:
hint_string = f"In the {hinted_area} area, you will find 1 progression item."
hint_string += "1 progression item."
if player_count > 1:
if local_lasers:
@ -434,7 +454,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items:
hint_string += "\nThis item is a laser."
else:
hint_string = f"In the {hinted_area} area, you will find {total_progression} progression items."
hint_string += f"{total_progression} progression items."
if local_lasers == total_progression:
sentence_end = (" for this world." if player_count > 1 else ".")
@ -471,7 +491,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items:
elif local_lasers:
hint_string += f"\n{local_lasers} of them are lasers."
return hint_string, total_progression
return hint_string, total_progression, hunt_panels
def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations: Set[Location]
@ -483,9 +503,9 @@ def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations
hints = []
for hinted_area in hinted_areas:
hint_string, prog_amount = word_area_hint(world, hinted_area, items_per_area[hinted_area])
hint_string, prog_amount, hunt_panels = word_area_hint(world, hinted_area, items_per_area[hinted_area])
hints.append(WitnessWordedHint(hint_string, None, f"hinted_area:{hinted_area}", prog_amount))
hints.append(WitnessWordedHint(hint_string, None, f"hinted_area:{hinted_area}", prog_amount, hunt_panels))
if len(hinted_areas) < amount:
player_name = world.multiworld.get_player_name(world.player)
@ -585,29 +605,42 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int,
return generated_hints
def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> CompactItemData:
def get_compact_hint_args(hint: WitnessWordedHint, local_player_number: int) -> CompactHintArgs:
"""
Arg reference:
Area Hint: 1st Arg is the amount of area progression and hunt panels. 2nd Arg is the name of the area.
Location Hint: 1st Arg is the location's address, second arg is the player number the location belongs to.
Junk Hint: 1st Arg is -1, second arg is this slot's player number.
"""
# Is Area Hint
if hint.area is not None:
assert hint.area_amount is not None, "Area hint had an undefined progression amount."
area_amount = hint.area_amount
hunt_panels = hint.area_hunt_panels
area_and_hunt_panels = area_amount
# Encode amounts together
if hunt_panels:
area_and_hunt_panels += 0x100 * hunt_panels
return hint.area, area_and_hunt_panels
location = hint.location
area_amount = hint.area_amount
# -1 if junk hint, address if location hint, area string if area hint
arg_1: Union[str, int]
# Is location hint
if location and location.address is not None:
arg_1 = location.address
elif hint.area is not None:
arg_1 = hint.area
else:
arg_1 = -1
return location.address, location.player
# self.player if junk hint, player if location hint, progression amount if area hint
arg_2: int
if area_amount is not None:
arg_2 = area_amount
elif location is not None:
arg_2 = location.player
else:
arg_2 = local_player_number
# Is junk / undefined hint
return -1, local_player_number
return hint.wording, arg_1, arg_2
def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> CompactHintData:
compact_arg_1, compact_arg_2 = get_compact_hint_args(hint, local_player_number)
return hint.wording, compact_arg_1, compact_arg_2
def make_laser_hints(world: "WitnessWorld", laser_names: List[str]) -> Dict[str, WitnessWordedHint]:

View File

@ -50,7 +50,7 @@ class WitnessPlayerLocations:
self.CHECK_PANELHEX_TO_ID = {
static_witness_logic.ENTITIES_BY_NAME[ch]["entity_hex"]: static_witness_locations.ALL_LOCATIONS_TO_ID[ch]
for ch in self.CHECK_LOCATIONS
if static_witness_logic.ENTITIES_BY_NAME[ch]["entityType"] in self.PANEL_TYPES_TO_SHUFFLE
if static_witness_logic.ENTITIES_BY_NAME[ch]["locationType"] in self.PANEL_TYPES_TO_SHUFFLE
}
dog_hex = static_witness_logic.ENTITIES_BY_NAME["Town Pet the Dog"]["entity_hex"]
@ -61,11 +61,9 @@ class WitnessPlayerLocations:
sorted(self.CHECK_PANELHEX_TO_ID.items(), key=lambda item: item[1])
)
event_locations = set(player_logic.USED_EVENT_NAMES_BY_HEX)
self.EVENT_LOCATION_TABLE = {
static_witness_locations.get_event_name(entity_hex): None
for entity_hex in event_locations
event_location: None
for event_location in player_logic.EVENT_ITEM_PAIRS
}
check_dict = {

View File

@ -173,6 +173,7 @@ class VictoryCondition(Choice):
- Challenge: Beat the secret Challenge (requires Challenge Lasers).
- Mountain Box Short: Input the short solution to the Mountaintop Box (requires Mountain Lasers).
- Mountain Box Long: Input the long solution to the Mountaintop Box (requires Challenge Lasers).
- Panel Hunt: Solve a specific number of randomly selected panels before going to the secret ending in Tutorial.
It is important to note that while the Mountain Box requires Desert Laser to be redirected in Town for that laser
to count, the laser locks on the Elevator and Challenge Timer panels do not.
@ -182,6 +183,62 @@ class VictoryCondition(Choice):
option_challenge = 1
option_mountain_box_short = 2
option_mountain_box_long = 3
option_panel_hunt = 4
class PanelHuntTotal(Range):
"""
Sets the number of random panels that will get marked as "Panel Hunt" panels in the "Panel Hunt" game mode.
"""
display_name = "Total Panel Hunt panels"
range_start = 5
range_end = 100
default = 40
class PanelHuntRequiredPercentage(Range):
"""
Determines the percentage of "Panel Hunt" panels that need to be solved to win.
"""
display_name = "Percentage of required Panel Hunt panels"
range_start = 20
range_end = 100
default = 63
class PanelHuntPostgame(Choice):
"""
In panel hunt, there are technically no postgame locations.
Depending on your options, this can leave Mountain and Caves as two huge areas with Hunt Panels in them that cannot be reached until you get enough lasers to go through the very linear Mountain descent.
Panel Hunt tends to be more fun when the world is open.
This option lets you force anything locked by lasers to be disabled, and thus ineligible for Hunt Panels.
To compensate, the respective mountain box solution (short box / long box) will be forced to be a Hunt Panel.
Does nothing if Panel Hunt is not your victory condition.
Note: The "Mountain Lasers" option may also affect locations locked by challenge lasers if the only path to those locations leads through the Mountain Entry.
"""
display_name = "Force postgame in Panel Hunt"
option_everything_is_eligible = 0
option_disable_mountain_lasers_locations = 1
option_disable_challenge_lasers_locations = 2
option_disable_anything_locked_by_lasers = 3
default = 3
class PanelHuntDiscourageSameAreaFactor(Range):
"""
The greater this value, the less likely it is that many Hunt Panels show up in the same area.
At 0, Hunt Panels will be selected randomly.
At 100, Hunt Panels will be almost completely evenly distributed between areas.
"""
display_name = "Panel Hunt Discourage Same Area Factor"
range_start = 0
range_end = 100
default = 40
class PuzzleRandomization(Choice):
@ -332,6 +389,10 @@ class TheWitnessOptions(PerGameCommonOptions):
victory_condition: VictoryCondition
mountain_lasers: MountainLasers
challenge_lasers: ChallengeLasers
panel_hunt_total: PanelHuntTotal
panel_hunt_required_percentage: PanelHuntRequiredPercentage
panel_hunt_postgame: PanelHuntPostgame
panel_hunt_discourage_same_area_factor: PanelHuntDiscourageSameAreaFactor
early_caves: EarlyCaves
early_symbol_item: EarlySymbolItem
elevators_come_to_you: ElevatorsComeToYou
@ -352,6 +413,12 @@ witness_option_groups = [
MountainLasers,
ChallengeLasers,
]),
OptionGroup("Panel Hunt Settings", [
PanelHuntRequiredPercentage,
PanelHuntTotal,
PanelHuntPostgame,
PanelHuntDiscourageSameAreaFactor,
], start_collapsed=True),
OptionGroup("Locations", [
ShuffleDiscardedPanels,
ShuffleVaultBoxes,

View File

@ -97,7 +97,7 @@ class WitnessPlayerItems:
# Add event items to the item definition list for later lookup.
for event_location in self._locations.EVENT_LOCATION_TABLE:
location_name = player_logic.EVENT_ITEM_PAIRS[event_location]
location_name = player_logic.EVENT_ITEM_PAIRS[event_location][0]
self.item_data[location_name] = ItemData(None, ItemDefinition(0, ItemCategory.EVENT),
ItemClassification.progression, False)

View File

@ -17,7 +17,6 @@ When the world has parsed its options, a second function is called to finalize t
import copy
from collections import defaultdict
from logging import warning
from typing import TYPE_CHECKING, Dict, List, Set, Tuple, cast
from .data import static_logic as static_witness_logic
@ -36,6 +35,7 @@ from .data.utils import (
get_early_caves_list,
get_early_caves_start_list,
get_elevators_come_to_you,
get_entity_hunt,
get_ep_all_individual,
get_ep_easy,
get_ep_no_eclipse,
@ -51,6 +51,7 @@ from .data.utils import (
logical_or_witness_rules,
parse_lambda,
)
from .entity_hunt import EntityHuntPicker
if TYPE_CHECKING:
from . import WitnessWorld
@ -60,7 +61,7 @@ class WitnessPlayerLogic:
"""WITNESS LOGIC CLASS"""
VICTORY_LOCATION: str
def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None:
self.YAML_DISABLED_LOCATIONS: Set[str] = disabled_locations
self.YAML_ADDED_ITEMS: Dict[str, int] = start_inv
@ -104,7 +105,7 @@ class WitnessPlayerLogic:
)
self.REQUIREMENTS_BY_HEX: Dict[str, WitnessRule] = {}
self.EVENT_ITEM_PAIRS: Dict[str, str] = {}
self.EVENT_ITEM_PAIRS: Dict[str, Tuple[str, str]] = {}
self.COMPLETELY_DISABLED_ENTITIES: Set[str] = set()
self.DISABLE_EVERYTHING_BEHIND: Set[str] = set()
self.PRECOMPLETED_LOCATIONS: Set[str] = set()
@ -112,6 +113,9 @@ class WitnessPlayerLogic:
self.ADDED_CHECKS: Set[str] = set()
self.VICTORY_LOCATION = "0x0356B"
self.PRE_PICKED_HUNT_ENTITIES: Set[str] = set()
self.HUNT_ENTITIES: Set[str] = set()
self.ALWAYS_EVENT_NAMES_BY_HEX = {
"0x00509": "+1 Laser (Symmetry Laser)",
"0x012FB": "+1 Laser (Desert Laser)",
@ -129,7 +133,7 @@ class WitnessPlayerLogic:
"0xFFF00": "Bottom Floor Discard Turns On",
}
self.USED_EVENT_NAMES_BY_HEX: Dict[str, str] = {}
self.USED_EVENT_NAMES_BY_HEX: Dict[str, List[str]] = {}
self.CONDITIONAL_EVENTS: Dict[Tuple[str, str], str] = {}
# The basic requirements to solve each entity come from StaticWitnessLogic.
@ -142,6 +146,10 @@ class WitnessPlayerLogic:
# This will make the access conditions way faster, instead of recursively checking dependent entities each time.
self.make_dependency_reduced_checklist()
if world.options.victory_condition == "panel_hunt":
picker = EntityHuntPicker(self, world, self.PRE_PICKED_HUNT_ENTITIES)
self.HUNT_ENTITIES = picker.pick_panel_hunt_panels(world.options.panel_hunt_total.value)
# Finalize which items actually exist in the MultiWorld and which get grouped into progressive items.
self.finalize_items()
@ -226,7 +234,7 @@ class WitnessPlayerLogic:
dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity, {})
if option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect",
"PP2 Weirdness", "Theater to Tunnels"}:
"PP2 Weirdness", "Theater to Tunnels", "Entity Hunt"}:
new_items = frozenset({frozenset([option_entity])})
elif option_entity in self.DISABLE_EVERYTHING_BEHIND:
new_items = frozenset()
@ -241,12 +249,12 @@ class WitnessPlayerLogic:
# If the dependent entity is unsolvable and is NOT an EP, this requirement option is invalid.
new_items = frozenset()
elif option_entity in self.ALWAYS_EVENT_NAMES_BY_HEX:
new_items = frozenset({frozenset([option_entity])})
new_items = frozenset({frozenset([self.ALWAYS_EVENT_NAMES_BY_HEX[option_entity]])})
elif (entity_hex, option_entity) in self.CONDITIONAL_EVENTS:
new_items = frozenset({frozenset([option_entity])})
self.USED_EVENT_NAMES_BY_HEX[option_entity] = self.CONDITIONAL_EVENTS[
(entity_hex, option_entity)
]
new_items = frozenset({frozenset([self.CONDITIONAL_EVENTS[(entity_hex, option_entity)]])})
self.USED_EVENT_NAMES_BY_HEX[option_entity].append(
self.CONDITIONAL_EVENTS[(entity_hex, option_entity)]
)
else:
new_items = theoretical_new_items
if dep_obj["region"] and entity_obj["region"] != dep_obj["region"]:
@ -404,7 +412,7 @@ class WitnessPlayerLogic:
line = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[line]["checkName"]
self.ADDED_CHECKS.add(line)
def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]:
def handle_regular_postgame(self, world: "WitnessWorld") -> List[List[str]]:
"""
In shuffle_postgame, panels that become accessible "after or at the same time as the goal" are disabled.
This mostly involves the disabling of key panels (e.g. long box when the goal is short box).
@ -435,6 +443,7 @@ class WitnessPlayerLogic:
# If we have a long box goal, Challenge is behind the amount of lasers required to just win.
# This is technically slightly incorrect as the Challenge Vault Box could contain a *symbol* that is required
# to open Mountain Entry (Stars 2). However, since there is a very easy sphere 1 snipe, this is not considered.
if victory == "mountain_box_long":
postgame_adjustments.append(["Disabled Locations:", "0x0A332 (Challenge Timer Start)"])
@ -479,6 +488,42 @@ class WitnessPlayerLogic:
return postgame_adjustments
def handle_panelhunt_postgame(self, world: "WitnessWorld") -> List[List[str]]:
postgame_adjustments = []
# Make some quick references to some options
panel_hunt_postgame = world.options.panel_hunt_postgame
chal_lasers = world.options.challenge_lasers
disable_mountain_lasers = (
panel_hunt_postgame == "disable_mountain_lasers_locations"
or panel_hunt_postgame == "disable_anything_locked_by_lasers"
)
disable_challenge_lasers = (
panel_hunt_postgame == "disable_challenge_lasers_locations"
or panel_hunt_postgame == "disable_anything_locked_by_lasers"
)
if disable_mountain_lasers:
self.DISABLE_EVERYTHING_BEHIND.add("0x09F7F") # Short box
self.PRE_PICKED_HUNT_ENTITIES.add("0x09F7F")
self.COMPLETELY_DISABLED_ENTITIES.add("0x3D9A9") # Elevator Start
# If mountain lasers are disabled, and challenge lasers > 7, the box will need to be rotated
if chal_lasers > 7:
postgame_adjustments.append([
"Requirement Changes:",
"0xFFF00 - 11 Lasers - True",
])
if disable_challenge_lasers:
self.DISABLE_EVERYTHING_BEHIND.add("0xFFF00") # Long box
self.PRE_PICKED_HUNT_ENTITIES.add("0xFFF00")
self.COMPLETELY_DISABLED_ENTITIES.add("0x0A332") # Challenge Timer
return postgame_adjustments
def make_options_adjustments(self, world: "WitnessWorld") -> None:
"""Makes logic adjustments based on options"""
adjustment_linesets_in_order = []
@ -500,10 +545,17 @@ class WitnessPlayerLogic:
self.VICTORY_LOCATION = "0x09F7F"
elif victory == "mountain_box_long":
self.VICTORY_LOCATION = "0xFFF00"
elif victory == "panel_hunt":
self.VICTORY_LOCATION = "0x03629"
self.COMPLETELY_DISABLED_ENTITIES.add("0x3352F")
# Exclude panels from the post-game if shuffle_postgame is false.
if not world.options.shuffle_postgame:
adjustment_linesets_in_order += self.handle_postgame(world)
if not world.options.shuffle_postgame and victory != "panel_hunt":
adjustment_linesets_in_order += self.handle_regular_postgame(world)
# Exclude panels from the post-game if shuffle_postgame is false.
if victory == "panel_hunt" and world.options.panel_hunt_postgame:
adjustment_linesets_in_order += self.handle_panelhunt_postgame(world)
# Exclude Discards / Vaults
if not world.options.shuffle_discarded_panels:
@ -570,6 +622,9 @@ class WitnessPlayerLogic:
if world.options.elevators_come_to_you:
adjustment_linesets_in_order.append(get_elevators_come_to_you())
if world.options.victory_condition == "panel_hunt":
adjustment_linesets_in_order.append(get_entity_hunt())
for item in self.YAML_ADDED_ITEMS:
adjustment_linesets_in_order.append(["Items:", item])
@ -603,7 +658,7 @@ class WitnessPlayerLogic:
if loc_obj["entityType"] == "EP":
self.COMPLETELY_DISABLED_ENTITIES.add(loc_obj["entity_hex"])
elif loc_obj["entityType"] in {"General", "Vault", "Discard"}:
elif loc_obj["entityType"] == "Panel":
self.EXCLUDED_LOCATIONS.add(loc_obj["entity_hex"])
for adjustment_lineset in adjustment_linesets_in_order:
@ -686,6 +741,7 @@ class WitnessPlayerLogic:
# Check if any regions have become unreachable.
reachable_regions = self.discover_reachable_regions()
new_unreachable_regions = all_regions - reachable_regions - self.UNREACHABLE_REGIONS
if new_unreachable_regions:
self.UNREACHABLE_REGIONS.update(new_unreachable_regions)
@ -741,9 +797,12 @@ class WitnessPlayerLogic:
if not self.solvability_guaranteed(entity) or entity in self.DISABLE_EVERYTHING_BEHIND:
individual_entity_requirements.append(frozenset())
# If a connection requires acquiring an event, add that event to its requirements.
elif (entity in self.ALWAYS_EVENT_NAMES_BY_HEX
or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX):
elif entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX:
individual_entity_requirements.append(frozenset({frozenset({entity})}))
elif entity in self.ALWAYS_EVENT_NAMES_BY_HEX:
individual_entity_requirements.append(
frozenset({frozenset({self.ALWAYS_EVENT_NAMES_BY_HEX[entity]})})
)
# If a connection requires entities, use their newly calculated independent requirements.
else:
entity_req = self.get_entity_requirement(entity)
@ -778,7 +837,7 @@ class WitnessPlayerLogic:
# We also clear any data structures that we might have filled in a previous dependency reduction
self.REQUIREMENTS_BY_HEX = {}
self.USED_EVENT_NAMES_BY_HEX = {}
self.USED_EVENT_NAMES_BY_HEX = defaultdict(list)
self.CONNECTIONS_BY_REGION_NAME = {}
self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set()
@ -868,7 +927,6 @@ class WitnessPlayerLogic:
"0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel
"0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge
"0x0CF2A": False, # Jungle Monastery Garden Shortcut
"0x17CAA": remote_doors, # Jungle Monastery Garden Shortcut Panel
"0x0364E": False, # Monastery Laser Shortcut Door
"0x03713": remote_doors, # Monastery Laser Shortcut Panel
"0x03313": False, # Orchard Second Gate
@ -884,23 +942,17 @@ class WitnessPlayerLogic:
# Jungle Popup Wall Panel
}
# In panel hunt, all panels are game, so all panels need to be reachable (unless disabled)
if goal == "panel_hunt":
for entity_hex in is_item_required_dict:
if static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Panel":
is_item_required_dict[entity_hex] = True
# Now, return the keys of the dict entries where the result is False to get unrequired major items
self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY |= {
item_name for item_name, is_required in is_item_required_dict.items() if not is_required
}
def make_event_item_pair(self, entity_hex: str) -> Tuple[str, str]:
"""
Makes a pair of an event panel and its event item
"""
action = " Opened" if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Door" else " Solved"
name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["checkName"] + action
if entity_hex not in self.USED_EVENT_NAMES_BY_HEX:
warning(f'Entity "{name}" does not have an associated event name.')
self.USED_EVENT_NAMES_BY_HEX[entity_hex] = name + " Event"
return (name, self.USED_EVENT_NAMES_BY_HEX[entity_hex])
def make_event_panel_lists(self) -> None:
"""
Makes event-item pairs for entities with associated events, unless these entities are disabled.
@ -908,13 +960,36 @@ class WitnessPlayerLogic:
self.ALWAYS_EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory"
self.USED_EVENT_NAMES_BY_HEX.update(self.ALWAYS_EVENT_NAMES_BY_HEX)
for event_hex, event_name in self.ALWAYS_EVENT_NAMES_BY_HEX.items():
self.USED_EVENT_NAMES_BY_HEX[event_hex].append(event_name)
self.USED_EVENT_NAMES_BY_HEX = {
event_hex: event_name for event_hex, event_name in self.USED_EVENT_NAMES_BY_HEX.items()
event_hex: event_list for event_hex, event_list in self.USED_EVENT_NAMES_BY_HEX.items()
if self.solvability_guaranteed(event_hex)
}
for panel in self.USED_EVENT_NAMES_BY_HEX:
pair = self.make_event_item_pair(panel)
self.EVENT_ITEM_PAIRS[pair[0]] = pair[1]
for entity_hex, event_names in self.USED_EVENT_NAMES_BY_HEX.items():
entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]
entity_name = entity_obj["checkName"]
entity_type = entity_obj["entityType"]
if entity_type == "Door":
action = " Opened"
elif entity_type == "Laser":
action = " Activated"
else:
action = " Solved"
for i, event_name in enumerate(event_names):
if i == 0:
self.EVENT_ITEM_PAIRS[entity_name + action] = (event_name, entity_hex)
else:
self.EVENT_ITEM_PAIRS[entity_name + action + f" (Effect {i + 1})"] = (event_name, entity_hex)
# Make Panel Hunt Events
for entity_hex in self.HUNT_ENTITIES:
entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]
entity_name = entity_obj["checkName"]
self.EVENT_ITEM_PAIRS[entity_name + " (Panel Hunt)"] = ("+1 Panel Hunt", entity_hex)
return

View File

@ -9,7 +9,6 @@ from BaseClasses import Entrance, Region
from worlds.generic.Rules import CollectionRule
from .data import static_locations as static_witness_locations
from .data import static_logic as static_witness_logic
from .data.static_logic import StaticWitnessLogicObj
from .data.utils import WitnessRule, optimize_witness_rule
@ -111,16 +110,24 @@ class WitnessPlayerRegions:
if k not in player_logic.UNREACHABLE_REGIONS
}
event_locations_per_region = defaultdict(list)
for event_location, event_item_and_entity in player_logic.EVENT_ITEM_PAIRS.items():
region = static_witness_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["region"]
if region is None:
region_name = "Entry"
else:
region_name = region["name"]
event_locations_per_region[region_name].append(event_location)
for region_name, region in regions_to_create.items():
locations_for_this_region = [
self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] for panel in region["entities"]
if self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"]
in self.player_locations.CHECK_LOCATION_TABLE
]
locations_for_this_region += [
static_witness_locations.get_event_name(panel) for panel in region["entities"]
if static_witness_locations.get_event_name(panel) in self.player_locations.EVENT_LOCATION_TABLE
]
locations_for_this_region += event_locations_per_region[region_name]
all_locations = all_locations | set(locations_for_this_region)

View File

@ -10,7 +10,6 @@ from worlds.generic.Rules import CollectionRule, set_rule
from .data import static_logic as static_witness_logic
from .data.utils import WitnessRule
from .locations import WitnessPlayerLocations
from .player_logic import WitnessPlayerLogic
if TYPE_CHECKING:
@ -31,42 +30,37 @@ laser_hexes = [
]
def _has_laser(laser_hex: str, world: "WitnessWorld", player: int, redirect_required: bool) -> CollectionRule:
if laser_hex == "0x012FB" and redirect_required:
return lambda state: (
_can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations)(state)
and state.has("Desert Laser Redirection", player)
)
def _can_do_panel_hunt(world: "WitnessWorld") -> CollectionRule:
required = world.panel_hunt_required_count
player = world.player
return lambda state: state.has("+1 Panel Hunt", player, required)
return _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations)
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)
def _has_lasers(amount: int, world: "WitnessWorld", redirect_required: bool) -> CollectionRule:
laser_lambdas = []
for laser_hex in laser_hexes:
has_laser_lambda = _has_laser(laser_hex, world, world.player, redirect_required)
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
def _can_solve_panel(panel: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic,
player_locations: WitnessPlayerLocations) -> CollectionRule:
"""
Determines whether a panel can be solved
"""
panel_obj = player_logic.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel]
entity_name = panel_obj["checkName"]
if entity_name + " Solved" in player_locations.EVENT_LOCATION_TABLE:
return lambda state: state.has(player_logic.EVENT_ITEM_PAIRS[entity_name + " Solved"], player)
return make_lambda(panel, world)
def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool:
"""
For Expert PP2, you need a way to access PP2 from the front, and a separate way from the back.
@ -202,8 +196,9 @@ def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") ->
)
def _has_item(item: str, world: "WitnessWorld", player: int,
player_logic: WitnessPlayerLogic, player_locations: WitnessPlayerLocations) -> CollectionRule:
def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic) -> CollectionRule:
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:
region = world.get_region(item)
return region.can_reach
@ -219,12 +214,13 @@ def _has_item(item: str, world: "WitnessWorld", player: int,
if item == "11 Lasers + Redirect":
laser_req = world.options.challenge_lasers.value
return _has_lasers(laser_req, world, True)
if item == "Entity Hunt":
# Right now, panel hunt is the only type of entity hunt. This may need to be changed later
return _can_do_panel_hunt(world)
if item == "PP2 Weirdness":
return lambda state: _can_do_expert_pp2(state, world)
if item == "Theater to Tunnels":
return lambda state: _can_do_theater_to_tunnels(state, world)
if item in player_logic.USED_EVENT_NAMES_BY_HEX:
return _can_solve_panel(item, world, player, player_logic, player_locations)
prog_item = static_witness_logic.get_parent_progressive_item(item)
return lambda state: state.has(prog_item, player, player_logic.MULTI_AMOUNTS[item])
@ -237,7 +233,7 @@ def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") -
"""
lambda_conversion = [
[_has_item(item, world, world.player, world.player_logic, world.player_locations) for item in subset]
[_has_item(item, world, world.player, world.player_logic) for item in subset]
for subset in requirements
]
@ -265,7 +261,8 @@ def set_rules(world: "WitnessWorld") -> None:
real_location = location
if location in world.player_locations.EVENT_LOCATION_TABLE:
real_location = location[:-7]
entity_hex = world.player_logic.EVENT_ITEM_PAIRS[location][1]
real_location = static_witness_logic.ENTITIES_BY_HEX[entity_hex]["checkName"]
associated_entity = world.player_logic.REFERENCE_LOGIC.ENTITIES_BY_NAME[real_location]
entity_hex = associated_entity["entity_hex"]

View File

@ -159,3 +159,36 @@ class WitnessMultiworldTestBase(MultiworldTestBase):
if isinstance(item_names, str):
item_names = (item_names,)
return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player]
def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None:
"""
Assert that a location exists in this world.
If strict_check, also make sure that this (non-event) location COULD exist.
"""
world = self.multiworld.worlds[player]
if strict_check:
self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist")
try:
world.get_location(location_name)
except KeyError:
self.fail(f"Location {location_name} does not exist.")
def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None:
"""
Assert that a location exists in this world.
If strict_check, be explicit about whether the location could exist in the first place.
"""
world = self.multiworld.worlds[player]
if strict_check:
self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist")
self.assertRaises(
KeyError,
lambda _: world.get_location(location_name),
f"Location {location_name} exists, but is not supposed to.",
)

View File

@ -0,0 +1,107 @@
from BaseClasses import CollectionState, Item
from worlds.witness.test import WitnessTestBase, WitnessMultiworldTestBase
class TestMaxPanelHuntMinChecks(WitnessTestBase):
options = {
"victory_condition": "panel_hunt",
"panel_hunt_total": 100,
"panel_hunt_required_percentage": 100,
"panel_hunt_postgame": "disable_anything_locked_by_lasers",
"disable_non_randomized_puzzles": True,
"shuffle_discarded_panels": False,
"shuffle_vault_boxes": False,
}
def test_correct_panels_were_picked(self):
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)
with self.subTest("Check that 100 Hunt Panels are enough"):
state_100 = CollectionState(self.multiworld)
panel_hunt_item = self.get_item_by_name("+1 Panel Hunt")
for _ in range(100):
state_100.collect(panel_hunt_item, True)
state_100.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")])
self.assertTrue(self.multiworld.completion_condition[self.player](state_100))
with self.subTest("Check that 99 Hunt Panels are not enough"):
state_99 = CollectionState(self.multiworld)
panel_hunt_item = self.get_item_by_name("+1 Panel Hunt")
for _ in range(99):
state_99.collect(panel_hunt_item, True)
state_99.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")])
self.assertFalse(self.multiworld.completion_condition[self.player](state_99))
class TestPanelHuntPostgame(WitnessMultiworldTestBase):
options_per_world = [
{
"panel_hunt_postgame": "everything_is_eligible"
},
{
"panel_hunt_postgame": "disable_mountain_lasers_locations"
},
{
"panel_hunt_postgame": "disable_challenge_lasers_locations"
},
{
"panel_hunt_postgame": "disable_anything_locked_by_lasers"
},
]
common_options = {
"victory_condition": "panel_hunt",
"panel_hunt_total": 40,
# Make sure we can check for Short vs Long Lasers locations by making Mountain Bottom Floor Discard accessible.
"shuffle_doors": "doors",
"shuffle_discarded_panels": True,
}
def test_panel_hunt_postgame(self):
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."):
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."):
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."):
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."):
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."):
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."):
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."):
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."):
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."):
self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 4)
self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 4)