diff --git a/worlds/zork_grand_inquisitor/client.py b/worlds/zork_grand_inquisitor/client.py index 49ccf6cd5b85..a9fa7a380b11 100644 --- a/worlds/zork_grand_inquisitor/client.py +++ b/worlds/zork_grand_inquisitor/client.py @@ -176,6 +176,8 @@ def on_package(self, cmd: str, _args: Any) -> None: id_to_landmarksanity()[_args["slot_data"]["landmarksanity"]] ) + self.game_controller.option_trap_percentage = _args["slot_data"]["trap_percentage"] + self.game_controller.option_grant_missable_location_checks = ( _args["slot_data"]["grant_missable_location_checks"] == 1 ) @@ -211,6 +213,10 @@ async def controller(self): # Enqueue Received Item Delta goal_item_count: int = 0 + trap_item_counts: Dict[ZorkGrandInquisitorItems, int] = { + item: 0 for item in self.game_controller.all_trap_items + } + network_item: NetUtils.NetworkItem for network_item in self.items_received: item: ZorkGrandInquisitorItems = self.id_to_items[network_item.item] @@ -218,8 +224,10 @@ async def controller(self): if item in self.game_controller.all_goal_items: goal_item_count += 1 continue - - if item not in self.game_controller.received_items: + elif item in self.game_controller.all_trap_items: + trap_item_counts[item] += 1 + continue + elif item not in self.game_controller.received_items: if item not in self.game_controller.received_items_queue: self.game_controller.received_items_queue.append(item) @@ -227,6 +235,8 @@ async def controller(self): self.game_controller.goal_item_count = goal_item_count self.game_controller.output_goal_item_update() + self.game_controller.trap_counters = trap_item_counts + # Game Controller Update if self.game_controller.is_process_running(): self.game_controller.update() diff --git a/worlds/zork_grand_inquisitor/data/item_data.py b/worlds/zork_grand_inquisitor/data/item_data.py index a91e4d82aa70..e7503d561008 100644 --- a/worlds/zork_grand_inquisitor/data/item_data.py +++ b/worlds/zork_grand_inquisitor/data/item_data.py @@ -1397,4 +1397,29 @@ class ZorkGrandInquisitorItemData(NamedTuple): classification=ItemClassification.progression, tags=(ZorkGrandInquisitorTags.GOAL_GRIM_JOURNEY,), ), + # Trap Items + ZorkGrandInquisitorItems.TRAP_INFINITE_CORRIDOR: ZorkGrandInquisitorItemData( + statemap_keys=None, + archipelago_id=ITEM_OFFSET + 900 + 0, + classification=ItemClassification.trap | ItemClassification.useful, + tags=(ZorkGrandInquisitorTags.TRAP,), + ), + ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: ZorkGrandInquisitorItemData( + statemap_keys=None, + archipelago_id=ITEM_OFFSET + 900 + 1, + classification=ItemClassification.trap, + tags=(ZorkGrandInquisitorTags.TRAP,), + ), + ZorkGrandInquisitorItems.TRAP_TELEPORT: ZorkGrandInquisitorItemData( + statemap_keys=None, + archipelago_id=ITEM_OFFSET + 900 + 2, + classification=ItemClassification.trap | ItemClassification.useful, + tags=(ZorkGrandInquisitorTags.TRAP,), + ), + ZorkGrandInquisitorItems.TRAP_ZVISION: ZorkGrandInquisitorItemData( + statemap_keys=None, + archipelago_id=ITEM_OFFSET + 900 + 3, + classification=ItemClassification.trap, + tags=(ZorkGrandInquisitorTags.TRAP,), + ), } diff --git a/worlds/zork_grand_inquisitor/data/mapping_data.py b/worlds/zork_grand_inquisitor/data/mapping_data.py index 4219d291fdc4..9eae2a87579b 100644 --- a/worlds/zork_grand_inquisitor/data/mapping_data.py +++ b/worlds/zork_grand_inquisitor/data/mapping_data.py @@ -757,6 +757,13 @@ ZorkGrandInquisitorStartingLocations.MONASTERY_EXHIBIT: ZorkGrandInquisitorRegions.MONASTERY_EXHIBIT, } +traps_to_game_state_key: Dict[ZorkGrandInquisitorItems, int] = { + ZorkGrandInquisitorItems.TRAP_INFINITE_CORRIDOR: 19990, + ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: 19991, + ZorkGrandInquisitorItems.TRAP_TELEPORT: 19992, + ZorkGrandInquisitorItems.TRAP_ZVISION: 19993, +} + voxam_cast_game_locations: Dict[ ZorkGrandInquisitorStartingLocations, Tuple[Tuple[str, int], ...] diff --git a/worlds/zork_grand_inquisitor/enums.py b/worlds/zork_grand_inquisitor/enums.py index 0e58f11bbf62..7b762e2768f6 100644 --- a/worlds/zork_grand_inquisitor/enums.py +++ b/worlds/zork_grand_inquisitor/enums.py @@ -259,6 +259,10 @@ class ZorkGrandInquisitorItems(enum.Enum): TOTEMIZER_DESTINATION_NEWARK_NEW_JERSEY = "Totemizer Destination: Newark, New Jersey" TOTEMIZER_DESTINATION_STRAIGHT_TO_HELL = "Totemizer Destination: Straight to Hell" TOTEMIZER_DESTINATION_SURFACE_OF_MERZ = "Totemizer Destination: Surface of Merz" + TRAP_INFINITE_CORRIDOR = "Infinite Corridor Trap" + TRAP_REVERSE_CONTROLS = "Reverse Controls Trap" + TRAP_TELEPORT = "Teleport Trap" + TRAP_ZVISION = "ZVision Trap" WELL_ROPE = "Well Rope" ZIMDOR_SCROLL = "ZIMDOR Scroll" ZORK_ROCKS = "Zork Rocks" @@ -534,3 +538,4 @@ class ZorkGrandInquisitorTags(enum.Enum): TELEPORTER_DESTINATION = "Teleporter Destination" TOTEMIZER_DESTINATION = "Totemizer Destination" TOTEM = "Totem" + TRAP = "Trap" diff --git a/worlds/zork_grand_inquisitor/game_controller.py b/worlds/zork_grand_inquisitor/game_controller.py index 4edec9f865a0..a435094f9196 100644 --- a/worlds/zork_grand_inquisitor/game_controller.py +++ b/worlds/zork_grand_inquisitor/game_controller.py @@ -1,4 +1,5 @@ import collections +import datetime import functools import logging import random @@ -14,6 +15,7 @@ death_cause_labels, hotspots_for_regional_hotspot, labels_for_enum_items, + traps_to_game_state_key, voxam_cast_game_locations, ) @@ -54,6 +56,7 @@ class GameController: all_spell_items: Set[ZorkGrandInquisitorItems] all_hotspot_items: Set[ZorkGrandInquisitorItems] all_goal_items: Set[ZorkGrandInquisitorItems] + all_trap_items: Set[ZorkGrandInquisitorItems] game_id_to_items: Dict[int, ZorkGrandInquisitorItems] @@ -76,6 +79,7 @@ class GameController: option_wild_voxam_chance: Optional[int] option_deathsanity: Optional[ZorkGrandInquisitorDeathsanity] option_landmarksanity: Optional[ZorkGrandInquisitorLandmarksanity] + option_trap_percentage: Optional[int] option_grant_missable_location_checks: Optional[bool] option_client_seed_information: Optional[ZorkGrandInquisitorClientSeedInformation] option_death_link: Optional[bool] @@ -83,6 +87,11 @@ class GameController: starter_kit: Optional[List[str]] initial_totemizer_destination: Optional[ZorkGrandInquisitorItems] + trap_counters: Dict[ZorkGrandInquisitorItems, int] + + active_trap: Optional[ZorkGrandInquisitorItems] + active_trap_until: Optional[datetime.datetime] + pending_death_link: Tuple[bool, Optional[str], Optional[str]] outgoing_death_link: Tuple[bool, Optional[str]] pause_death_monitoring: bool @@ -117,6 +126,8 @@ def __init__(self, logger=None) -> None: ZorkGrandInquisitorItems.DEATH, } + self.all_trap_items = items_with_tag(ZorkGrandInquisitorTags.TRAP) + self.game_id_to_items = game_id_to_items() self.possible_inventory_items = ( @@ -142,6 +153,7 @@ def __init__(self, logger=None) -> None: self.option_wild_voxam_chance = None self.option_deathsanity = None self.option_landmarksanity = None + self.option_trap_percentage = None self.option_grant_missable_location_checks = None self.option_client_seed_information = None self.option_death_link = None @@ -149,6 +161,16 @@ def __init__(self, logger=None) -> None: self.starter_kit = None self.initial_totemizer_destination = None + self.trap_counters = { + ZorkGrandInquisitorItems.TRAP_INFINITE_CORRIDOR: 0, + ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: 0, + ZorkGrandInquisitorItems.TRAP_TELEPORT: 0, + ZorkGrandInquisitorItems.TRAP_ZVISION: 0, + } + + self.active_trap = None + self.active_trap_until = None + self.pending_death_link = (False, None, None) self.outgoing_death_link = (False, None) self.pause_death_monitoring = False @@ -246,6 +268,7 @@ def output_seed_information(self) -> None: self.log(f" Deathsanity: {labels_for_enum_items[self.option_deathsanity]}") self.log(f" Landmarksanity: {labels_for_enum_items[self.option_landmarksanity]}") + self.log(f" Trap Percentage: {self.option_trap_percentage}%") if self.option_grant_missable_location_checks: self.log(f" Grant Missable Location Checks: On") @@ -396,6 +419,9 @@ def update(self) -> None: self._apply_conditional_teleports() + if self.option_trap_percentage: + self._manage_traps() + if self.option_death_link: self._handle_death_link() @@ -428,6 +454,7 @@ def reset(self) -> None: self.option_wild_voxam_chance = None self.option_deathsanity = None self.option_landmarksanity = None + self.option_trap_percentage = None self.option_grant_missable_location_checks = None self.option_client_seed_information = None self.option_death_link = None @@ -435,6 +462,16 @@ def reset(self) -> None: self.starter_kit = None self.initial_totemizer_destination = None + self.trap_counters = { + ZorkGrandInquisitorItems.TRAP_INFINITE_CORRIDOR: 0, + ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: 0, + ZorkGrandInquisitorItems.TRAP_TELEPORT: 0, + ZorkGrandInquisitorItems.TRAP_ZVISION: 0, + } + + self.active_trap = None + self.active_trap_until = None + self.pending_death_link = (False, None, None) self.outgoing_death_link = (False, None) self.pause_death_monitoring = False @@ -1349,13 +1386,13 @@ def _apply_conditional_teleports(self) -> None: if zork_rocks_inert: self._cast_voxam() - def _cast_voxam(self) -> None: - if not self.option_wild_voxam: + def _cast_voxam(self, force_wild: bool = False) -> None: + if not self.option_wild_voxam and not force_wild: self._apply_starting_location(force=True) voxam_roll: int = random.randint(1, 100) - if voxam_roll <= self.option_wild_voxam_chance: + if voxam_roll <= self.option_wild_voxam_chance or force_wild: starting_location: ZorkGrandInquisitorStartingLocations = ( random.choice(tuple(voxam_cast_game_locations.keys())) ) @@ -1375,6 +1412,80 @@ def _cast_voxam(self) -> None: else: self._apply_starting_location(force=True) + def _manage_traps(self) -> None: + if not self._player_is_afgncaap() or self._read_game_state_value_for(19985) == 0: + return None + + if self.active_trap_until: + if datetime.datetime.now() > self.active_trap_until: + if self.active_trap == ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: + self._deactivate_trap_reverse_controls() + elif self.active_trap == ZorkGrandInquisitorItems.TRAP_ZVISION: + self._deactivate_trap_zvision() + + self.active_trap = None + self.active_trap_until = None + + if self.active_trap is not None: + if self.active_trap == ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: + self._activate_trap_reverse_controls() + elif self.active_trap == ZorkGrandInquisitorItems.TRAP_ZVISION: + self._activate_trap_zvision() + + return None + + trap: ZorkGrandInquisitorItems + count: int + for trap, count in self.trap_counters.items(): + game_count: int = self._read_game_state_value_for(traps_to_game_state_key[trap]) + + if game_count < count: + if trap == ZorkGrandInquisitorItems.TRAP_INFINITE_CORRIDOR: + self._activate_trap_infinite_corridor() + elif trap == ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS: + self.active_trap = ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS + self.active_trap_until = datetime.datetime.now() + datetime.timedelta(seconds=30) + + self._activate_trap_reverse_controls() + elif trap == ZorkGrandInquisitorItems.TRAP_TELEPORT: + self._activate_trap_teleport() + elif trap == ZorkGrandInquisitorItems.TRAP_ZVISION: + self.active_trap = ZorkGrandInquisitorItems.TRAP_ZVISION + self.active_trap_until = datetime.datetime.now() + datetime.timedelta(seconds=30) + + self._activate_trap_zvision() + + self._write_game_state_value_for(traps_to_game_state_key[trap], count) + self.trap_counters[trap] = game_count + + break + + def _activate_trap_infinite_corridor(self) -> None: + depth = random.randint(10, 20) + + self._write_game_state_value_for(11005, depth) + self.game_state_manager.set_game_location("th20", random.randint(0, 1800)) + + time.sleep(0.1) + + self._write_game_state_value_for(11005, depth) + + def _activate_trap_reverse_controls(self) -> None: + self.game_state_manager.set_panorama_reversed(True) + + def _deactivate_trap_reverse_controls(self) -> None: + self.game_state_manager.set_panorama_reversed(False) + + def _activate_trap_teleport(self) -> None: + self._cast_voxam(force_wild=True) + time.sleep(0.1) + + def _activate_trap_zvision(self) -> None: + self.game_state_manager.set_zvision(True) + + def _deactivate_trap_zvision(self) -> None: + self.game_state_manager.set_zvision(False) + def _handle_death_link(self) -> None: # Pause Monitoring Flag if self.pause_death_monitoring and not self._player_is_at("gjde"): diff --git a/worlds/zork_grand_inquisitor/game_state_manager.py b/worlds/zork_grand_inquisitor/game_state_manager.py index 25b35969bf5e..31a168404be7 100644 --- a/worlds/zork_grand_inquisitor/game_state_manager.py +++ b/worlds/zork_grand_inquisitor/game_state_manager.py @@ -82,6 +82,14 @@ def next_location_address(self) -> int: def next_location_offset_address(self) -> int: return self.script_manager_struct_address + 0x40C + @property + def zvision_address(self) -> int: + return self.render_manager_struct_address + 0x0 + + @property + def render_type_address(self) -> int: + return self.render_manager_struct_address + 0x10 + @property def panorama_reversed_address(self) -> int: return self.render_manager_struct_address + 0x1C @@ -250,6 +258,22 @@ def set_game_location(self, game_location: str, offset: int) -> Optional[bool]: return None + def set_zvision(self, is_zvision: bool) -> Optional[bool]: + if self.is_process_running: + self.process.write_int(self.zvision_address, 320 if is_zvision else 640) + + return True + + return None + + def set_render_type(self, render_type: int) -> Optional[bool]: + if self.is_process_running: + self.process.write_int(self.render_type_address, render_type) + + return True + + return None + def set_panorama_reversed(self, is_reversed: bool) -> Optional[bool]: if self.is_process_running: self.process.write_int(self.panorama_reversed_address, 1 if is_reversed else 0) diff --git a/worlds/zork_grand_inquisitor/options.py b/worlds/zork_grand_inquisitor/options.py index 493c780401a3..69f956224291 100644 --- a/worlds/zork_grand_inquisitor/options.py +++ b/worlds/zork_grand_inquisitor/options.py @@ -1,9 +1,12 @@ +from typing import List + from dataclasses import dataclass from Options import ( Choice, DeathLinkMixin, DefaultOnToggle, + OptionGroup, PerGameCommonOptions, Range, StartInventoryPool, @@ -199,6 +202,85 @@ class Landmarksanity(DefaultOnToggle): display_name: str = "Landmarksanity" +class TrapPercentage(Range): + """ + Determines the percentage chance that a trap will replace a filler item. + + Possible traps are: + - Infinite Corridor Trap: The player is teleported to a random depth in the Infinite Corridor + - Reverse Controls Trap: The player's panorama controls are reversed for 30 seconds + - Teleport Trap: The player is teleported to a random location + - ZVision Trap: The player's vision is obscured for 30 seconds + """ + + display_name = "Trap Percentage" + + range_start = 0 + range_end = 100 + + default = 0 + + +class InfiniteCorridorTrapWeight(Range): + """ + Determines the weight of the Infinite Corridor Trap. + + The higher the weight, the more likely this trap will be chosen when a trap is rolled. + """ + + display_name = "Infinite Corridor Trap Weight" + + range_start = 0 + range_end = 100 + + default = 1 + + +class ReverseControlsTrapWeight(Range): + """ + Determines the weight of the Reverse Controls Trap. + + The higher the weight, the more likely this trap will be chosen when a trap is rolled. + """ + + display_name = "Reverse Controls Trap Weight" + + range_start = 0 + range_end = 100 + + default = 1 + + +class TeleportTrapWeight(Range): + """ + Determines the weight of the Teleport Trap. + + The higher the weight, the more likely this trap will be chosen when a trap is rolled. + """ + + display_name = "Teleport Trap Weight" + + range_start = 0 + range_end = 100 + + default = 1 + + +class ZVisionTrapWeight(Range): + """ + Determines the weight of the ZVision Trap. + + The higher the weight, the more likely this trap will be chosen when a trap is rolled. + """ + + display_name = "ZVision Trap Weight" + + range_start = 0 + range_end = 100 + + default = 1 + + class GrantMissableLocationChecks(Toggle): """ If true, performing an irreversible action will grant the locations checks that would have become unobtainable as a @@ -245,5 +327,55 @@ class ZorkGrandInquisitorOptions(PerGameCommonOptions, DeathLinkMixin): wild_voxam_chance: WildVoxamChance deathsanity: Deathsanity landmarksanity: Landmarksanity + trap_percentage: TrapPercentage + infinite_corridor_trap_weight: InfiniteCorridorTrapWeight + reverse_controls_trap_weight: ReverseControlsTrapWeight + teleport_trap_weight: TeleportTrapWeight + zvision_trap_weight: ZVisionTrapWeight grant_missable_location_checks: GrantMissableLocationChecks client_seed_information: ClientSeedInformation + + +# Option presets here... + +option_groups: List[OptionGroup] = [ + OptionGroup( + "Goal Options", + [ + Goal, + ArtifactsOfMagicTotal, + ArtifactsOfMagicRequired, + LandmarksRequired, + DeathsRequired, + ], + ), + OptionGroup( + "Gameplay Options", + [ + StartingLocation, + Hotspots, + CraftableSpells, + WildVoxam, + WildVoxamChance, + Deathsanity, + Landmarksanity, + ], + ), + OptionGroup( + "Trap Options", + [ + TrapPercentage, + InfiniteCorridorTrapWeight, + ReverseControlsTrapWeight, + TeleportTrapWeight, + ZVisionTrapWeight, + ], + ), + OptionGroup( + "Client Options", + [ + GrantMissableLocationChecks, + ClientSeedInformation, + ], + ), +] diff --git a/worlds/zork_grand_inquisitor/world.py b/worlds/zork_grand_inquisitor/world.py index 176b3dc45e50..fd44ec79f656 100644 --- a/worlds/zork_grand_inquisitor/world.py +++ b/worlds/zork_grand_inquisitor/world.py @@ -1,6 +1,9 @@ +import logging + from typing import Any, Dict, List, Set, Tuple, Union from BaseClasses import Item, ItemClassification, Location, Region, Tutorial +from Options import OptionError from worlds.AutoWorld import WebWorld, World @@ -52,7 +55,7 @@ ZorkGrandInquisitorTags, ) -from .options import ZorkGrandInquisitorOptions +from .options import ZorkGrandInquisitorOptions, option_groups class ZorkGrandInquisitorItem(Item): @@ -77,6 +80,9 @@ class ZorkGrandInquisitorWebWorld(WebWorld): ) ] + # Option presets here... + option_groups = option_groups + class ZorkGrandInquisitorWorld(World): """ @@ -125,6 +131,8 @@ class ZorkGrandInquisitorWorld(World): locked_items: Dict[ZorkGrandInquisitorLocations, ZorkGrandInquisitorItems] starter_kit: Tuple[ZorkGrandInquisitorItems, ...] starting_location: ZorkGrandInquisitorStartingLocations + trap_percentage: int + trap_weights: Tuple[int, ...] def generate_early(self) -> None: self.goal = id_to_goals()[self.options.goal.value] @@ -135,6 +143,12 @@ def generate_early(self) -> None: if self.artifacts_of_magic_required > self.artifacts_of_magic_total: self.artifacts_of_magic_total = self.artifacts_of_magic_required + if self.goal == ZorkGrandInquisitorGoals.ARTIFACT_OF_MAGIC_HUNT: + logging.warning( + f"Zork Grand Inquisitor: {self.player_name} has more required artifacts than " + "total artifacts. Using required artifacts as total artifacts..." + ) + self.landmarks_required = self.options.landmarks_required.value self.deaths_required = self.options.deaths_required.value @@ -191,6 +205,20 @@ def generate_early(self) -> None: self.initial_totemizer_destination = self._select_initial_totemizer_destination() + self.trap_percentage = self.options.trap_percentage.value / 100 + + self.trap_weights = ( + self.options.infinite_corridor_trap_weight.value, + self.options.reverse_controls_trap_weight.value, + self.options.teleport_trap_weight.value, + self.options.zvision_trap_weight.value, + ) + + if self.trap_percentage and not any(self.trap_weights): + raise OptionError( + f"Zork Grand Inquisitor: {self.player_name} has traps enabled but all traps are weighted at 0." + ) + def create_regions(self) -> None: region_mapping: Dict[ZorkGrandInquisitorRegions, Region] = dict() @@ -287,29 +315,23 @@ def create_regions(self) -> None: self.multiworld.regions.append(region_menu) def create_items(self) -> None: - items_to_ignore: Set[ZorkGrandInquisitorItems] = set() - items_to_precollect: Set[ZorkGrandInquisitorItems] = set() - items_to_place_early: Set[ZorkGrandInquisitorItems] - - item: ZorkGrandInquisitorItems - - for item in items_with_tag(ZorkGrandInquisitorTags.FILLER): - items_to_ignore.add(item) - - for item in items_with_tag(ZorkGrandInquisitorTags.GOAL_THREE_ARTIFACTS): - items_to_ignore.add(item) + # Populate Items to Ignore and Precollect + items_to_ignore: Set[ZorkGrandInquisitorItems] = ( + items_with_tag(ZorkGrandInquisitorTags.FILLER) + | items_with_tag(ZorkGrandInquisitorTags.TRAP) + | items_with_tag(ZorkGrandInquisitorTags.GOAL_THREE_ARTIFACTS) + | items_with_tag(ZorkGrandInquisitorTags.GOAL_ZORK_TOUR) + | items_with_tag(ZorkGrandInquisitorTags.GOAL_GRIM_JOURNEY) + | set(self.locked_items.values()) + ) if self.goal != ZorkGrandInquisitorGoals.ARTIFACT_OF_MAGIC_HUNT: - items_to_ignore.add(ZorkGrandInquisitorItems.ARTIFACT_OF_MAGIC) + items_to_ignore |= items_with_tag(ZorkGrandInquisitorTags.GOAL_ARTIFACT_OF_MAGIC_HUNT) - items_to_ignore.add(ZorkGrandInquisitorItems.LANDMARK) - items_to_ignore.add(ZorkGrandInquisitorItems.DEATH) - - for item in self.locked_items.values(): - items_to_ignore.add(item) - - for item in self.starter_kit: - items_to_precollect.add(item) + items_to_precollect: Set[ZorkGrandInquisitorItems] = ( + set(self.starter_kit) + | {self.initial_totemizer_destination} + ) hotspot_items: Set[ZorkGrandInquisitorItems] = items_with_tag(ZorkGrandInquisitorTags.HOTSPOT) @@ -318,19 +340,12 @@ def create_items(self) -> None: ) if self.hotspots == ZorkGrandInquisitorHotspots.ENABLED: - for item in hotspot_items: - items_to_ignore.add(item) - - for item in hotspot_regional_items: - items_to_precollect.add(item) + items_to_ignore |= hotspot_items + items_to_precollect |= hotspot_regional_items elif self.hotspots == ZorkGrandInquisitorHotspots.REQUIRE_ITEM_PER_REGION: - for item in hotspot_items: - items_to_ignore.add(item) + items_to_ignore |= hotspot_items elif self.hotspots == ZorkGrandInquisitorHotspots.REQUIRE_ITEM_PER_HOTSPOT: - for item in hotspot_regional_items: - items_to_ignore.add(item) - - items_to_precollect.add(self.initial_totemizer_destination) + items_to_ignore |= hotspot_regional_items if self.starting_location != ZorkGrandInquisitorStartingLocations.DM_LAIR_INTERIOR: items_to_precollect.add(ZorkGrandInquisitorItems.HOTSPOT_DUNGEON_MASTERS_HOUSE_EXIT) @@ -338,14 +353,14 @@ def create_items(self) -> None: if self.starting_location != ZorkGrandInquisitorStartingLocations.SPELL_LAB: items_to_precollect.add(ZorkGrandInquisitorItems.HOTSPOT_SPELL_LAB_BRIDGE_EXIT) - items_to_place_early = set(self.early_items) - items_to_precollect + items_to_ignore |= items_to_precollect # Create Item Pool item_pool: List[ZorkGrandInquisitorItem] = list() data: ZorkGrandInquisitorItemData for item, data in self.item_data.items(): - if item in items_to_ignore or item in items_to_precollect: + if item in items_to_ignore: continue if item == ZorkGrandInquisitorItems.ARTIFACT_OF_MAGIC: @@ -354,8 +369,19 @@ def create_items(self) -> None: else: item_pool.append(self.create_item(item.value)) - total_locations: int = len(self.multiworld.get_unfilled_locations(self.player)) - item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))] + total_location_count: int = len(self.multiworld.get_unfilled_locations(self.player)) + to_fill_location_count: int = total_location_count - len(item_pool) + + trap_count: int = int(round(to_fill_location_count * self.trap_percentage)) + + if trap_count: + item_pool += [ + self.create_item(trap.value) for trap in self._sample_trap_items(trap_count) + ] + + item_pool += [ + self.create_filler() for _ in range(to_fill_location_count - trap_count) + ] self.multiworld.itempool += item_pool @@ -363,7 +389,11 @@ def create_items(self) -> None: for item in items_to_precollect: self.multiworld.push_precollected(self.create_item(item.value)) - # Early Items + # Define Early Items + items_to_place_early: Set[ZorkGrandInquisitorItems] = ( + set(self.early_items) - items_to_ignore + ) + if len(items_to_place_early): for item in items_to_place_early: self.multiworld.early_items[self.player][item.value] = 1 @@ -395,6 +425,7 @@ def fill_slot_data(self) -> Dict[str, Any]: "wild_voxam_chance", "deathsanity", "landmarksanity", + "trap_percentage", "grant_missable_location_checks", "client_seed_information", "death_link", @@ -526,3 +557,15 @@ def _select_initial_totemizer_destination(self) -> ZorkGrandInquisitorItems: ZorkGrandInquisitorItems.TOTEMIZER_DESTINATION_INFINITY, ZorkGrandInquisitorItems.TOTEMIZER_DESTINATION_STRAIGHT_TO_HELL, )) + + def _sample_trap_items(self, count: int) -> List[ZorkGrandInquisitorItems]: + return self.random.choices( + ( + ZorkGrandInquisitorItems.TRAP_INFINITE_CORRIDOR, + ZorkGrandInquisitorItems.TRAP_REVERSE_CONTROLS, + ZorkGrandInquisitorItems.TRAP_TELEPORT, + ZorkGrandInquisitorItems.TRAP_ZVISION, + ), + weights=self.trap_weights, + k=count, + )