From 17f753281fd62fc12f494269986cde7db480896e Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:45:17 +0200 Subject: [PATCH] The Witness: Universal Tracker support + Clean up which options actually need to be in slot_data (#5503) * Re-gen with UT + slim slot_data slightly * make use of relevance in ut code * That's not how enums work * fixup * more ut compat * more ut fixup * more ut fixup * utils entity hex fixup * Me when I lie * Update options.py --- worlds/witness/__init__.py | 34 +++-- worlds/witness/data/utils.py | 4 + worlds/witness/options.py | 191 ++++++++++++++++++++++------ worlds/witness/player_logic.py | 35 ++++- worlds/witness/universal_tracker.py | 48 +++++++ 5 files changed, 256 insertions(+), 56 deletions(-) create mode 100644 worlds/witness/universal_tracker.py diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 19b1b800ec6..581bd6ba1d7 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -3,7 +3,7 @@ Archipelago init file for The Witness """ import dataclasses from logging import error, info, warning -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict, List, Optional, cast, ClassVar from BaseClasses import CollectionState, Entrance, Item, Location, LocationProgressType, Region, Tutorial @@ -17,13 +17,14 @@ from .data.item_definition_classes import DoorItemDefinition, ItemData from .data.utils import cast_not_none, get_audio_logs 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 .options import RelevanceMixin, TheWitnessOptions, witness_option_groups from .place_early_item import place_early_items from .player_items import WitnessItem, WitnessPlayerItems from .player_logic import WitnessPlayerLogic from .presets import witness_option_presets from .regions import WitnessPlayerRegions from .rules import set_rules +from .universal_tracker import set_options_from_ut class WitnessWebWorld(WebWorld): @@ -84,6 +85,8 @@ class WitnessWorld(World): required_client_version = (0, 6, 8) + ut_can_gen_without_yaml: ClassVar[bool] = True + player_logic: WitnessPlayerLogic player_locations: WitnessPlayerLocations player_items: WitnessPlayerItems @@ -157,6 +160,16 @@ class WitnessWorld(World): f" Shuffle, Door Shuffle, or Obelisk Keys.") def generate_early(self) -> None: + # Do this first, as set_options_from_ut might re-set it to something different + 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 + + set_options_from_ut(self) + disabled_locations = self.options.exclude_locations.value self.player_logic = WitnessPlayerLogic( @@ -178,13 +191,6 @@ class WitnessWorld(World): if item_data.local_only: self.options.local_items.value.add(item_name) - 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) @@ -405,11 +411,11 @@ class WitnessWorld(World): slot_data = self._get_slot_data() - for option_name in (attr.name for attr in dataclasses.fields(TheWitnessOptions) - if attr not in dataclasses.fields(PerGameCommonOptions)): - option = getattr(self.options, option_name) - slot_data[option_name] = bool(option.value) if isinstance(option, Toggle) else option.value - + slot_data_options = [ + option_name for option_name, option_type in TheWitnessOptions.type_hints.items() + if issubclass(option_type, RelevanceMixin) and option_type.needs_to_be_in_slot_data + ] + slot_data |= self.options.as_dict(*slot_data_options, toggles_as_bools=True) return slot_data def create_item(self, item_name: str) -> WitnessItem: diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index 2de2e5c9191..b53a4ac02bb 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -270,3 +270,7 @@ def is_easter_time() -> bool: last_easter_day = date(today.year, 4, 26) # Latest possible is 4/25 + 1 day buffer for Easter Monday return earliest_easter_day <= today <= last_easter_day + + +def entity_id_int_to_canonical_entity_id_string(entity_id_int: int) -> str: + return f"0x{entity_id_int:0{5}X}" diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 2fcd35b64c5..3776bd6a2c9 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -1,10 +1,12 @@ from dataclasses import dataclass +from enum import IntFlag from typing import Tuple from Options import ( Choice, DefaultOnToggle, LocationSet, + Option, OptionCounter, OptionError, OptionGroup, @@ -22,7 +24,26 @@ from .data.utils import is_easter_time from .entity_hunt import ALL_HUNTABLE_PANELS -class DisableNonRandomizedPuzzles(Toggle): +class OptionRelevance(IntFlag): + client = 0b001 + universal_tracker_regeneration = 0b010 + external_tracker = 0b100 + + none = 0b000 + all = 0b111 + + all_trackers = 0b110 + + +class RelevanceMixin: + relevance: OptionRelevance = OptionRelevance.none + + @property + def needs_to_be_in_slot_data(self): + return self.relevance != OptionRelevance.none + + +class DisableNonRandomizedPuzzles(RelevanceMixin, Toggle): """ Disables puzzles that cannot be randomized. This includes many puzzles that heavily involve the environment, such as Shadows, Monastery or Orchard. @@ -30,16 +51,20 @@ class DisableNonRandomizedPuzzles(Toggle): The lasers for those areas will activate as you solve optional puzzles, such as Discarded Panels. Additionally, the panel activating the Jungle Popup Wall will be on from the start. """ + relevance = OptionRelevance.all + display_name = "Disable non randomized puzzles" -class EarlyCaves(Choice): +class EarlyCaves(RelevanceMixin, Choice): """ Adds an item that opens the Caves Shortcuts to Swamp and Mountain, allowing early access to the Caves even if you are not playing a remote Door Shuffle mode. You can either add this item to the pool to be found in the multiworld, or you can outright start with it and have immediate access to the Caves. If you choose "Add To Pool" and you are already playing a remote Door Shuffle mode, this option will do nothing. """ + relevance = OptionRelevance.all_trackers + display_name = "Early Caves" option_off = 0 alias_false = 0 @@ -49,7 +74,7 @@ class EarlyCaves(Choice): alias_on = 2 -class EarlyGoodItems(OptionSet): +class EarlyGoodItems(RelevanceMixin, OptionSet): """ Put one random helpful item of each of the chosen types on an early check, specifically a sphere 1 Tutorial location. If a type is chosen, but no items of that type exist in the itempool, it is skipped. @@ -59,22 +84,25 @@ class EarlyGoodItems(OptionSet): If there still aren't enough sphere 1 Tutorial locations, a random local sphere 1 location is picked. If no local sphere 1 locations are available, there are no further attempts to place the item. """ + relevance = OptionRelevance.none valid_keys = {"Symbol", "Door / Door Panel", "Obelisk Key"} default = frozenset({"Symbol"}) -class ShuffleSymbols(DefaultOnToggle): +class ShuffleSymbols(RelevanceMixin, DefaultOnToggle): """ If on, you will need to unlock puzzle symbols as items to be able to solve the panels that contain those symbols. Please note that there is no minimum set of progression items in this randomizer. If you turn this option off and don't turn on door shuffle or obelisk keys, there will be no progression items, which will disallow you from adding your yaml to a multiworld generation. """ + relevance = OptionRelevance.all + display_name = "Shuffle Symbols" -class ProgressiveSymbols(OptionSet): +class ProgressiveSymbols(RelevanceMixin, OptionSet): """ Make some symbols progressive, if they exist. @@ -87,6 +115,8 @@ class ProgressiveSymbols(OptionSet): Progressive Shapers: Shapers -> Rotated Shapers -> Negative Shapers Progressive Discard Symbols: Triangles -> Arrows """ + relevance = OptionRelevance.all_trackers # Client gets this info through chains in slot_data + display_name = "Progressive Symbols" valid_keys = { @@ -101,7 +131,7 @@ class ProgressiveSymbols(OptionSet): default = frozenset({"Progressive Dots", "Progressive Symmetry", "Progressive Stars"}) -class SecondStageSymbolsActIndependently(OptionSet): +class SecondStageSymbolsActIndependently(RelevanceMixin, OptionSet): """ Makes certain second stage symbols act independently of first stage symbols if they are not progressive. @@ -111,6 +141,7 @@ class SecondStageSymbolsActIndependently(OptionSet): Rotated Shapers always act independently from Shapers. The ability to make them dependent on Shapers by omitting them in this option may be added in the future. """ + relevance = OptionRelevance.all valid_keys = { "Full Dots", @@ -123,27 +154,31 @@ class SecondStageSymbolsActIndependently(OptionSet): visibility = Visibility.template | Visibility.complex_ui -class ColoredDotsAreProgressiveDots(Toggle): +class ColoredDotsAreProgressiveDots(RelevanceMixin, Toggle): """ Put Colored Dots into the "Progressive Dots" group, after Dots. This removes Progressive Symmetry. """ + relevance = OptionRelevance.all_trackers # Client gets this info through chains in slot_data visibility = Visibility.template | Visibility.complex_ui -class SoundDotsAreProgressiveDots(Toggle): +class SoundDotsAreProgressiveDots(RelevanceMixin, Toggle): """ Put Sound Dots into the "Progressive Dots" group, before Full Dots. """ + relevance = OptionRelevance.all_trackers # Client gets this info through chains in slot_data visibility = Visibility.template | Visibility.complex_ui -class ShuffleLasers(Choice): +class ShuffleLasers(RelevanceMixin, Choice): """ If on, the 11 lasers are turned into items and will activate on their own upon receiving them. """ + relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data + display_name = "Shuffle Lasers" option_off = 0 alias_false = 0 @@ -153,13 +188,15 @@ class ShuffleLasers(Choice): alias_on = 2 -class ShuffleDoors(Choice): +class ShuffleDoors(RelevanceMixin, Choice): """ If on, opening doors, moving bridges etc. will require a "key". - Panels: The panel on the door will be locked until receiving its corresponding key. - Doors: The door will open immediately upon receiving its key. Door panels are added as location checks. - Mixed: Includes all doors from "doors", and all control panels (bridges, elevators etc.) from "panels". """ + relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data + display_name = "Shuffle Doors" option_off = 0 option_panels = 1 @@ -167,42 +204,50 @@ class ShuffleDoors(Choice): option_mixed = 3 -class DoorGroupings(Choice): +class DoorGroupings(RelevanceMixin, Choice): """ Controls how door items are grouped. - Off: There will be one key for each door, potentially resulting in upwards of 120 keys being added to the item pool. - Regional: All doors in the same general region will open at once with a single key, reducing the amount of door items and complexity. """ + relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data + display_name = "Door Groupings" option_off = 0 option_regional = 1 -class ShuffleBoat(DefaultOnToggle): +class ShuffleBoat(RelevanceMixin, DefaultOnToggle): """ If on, adds a "Boat" item to the item pool. Before receiving this item, you will not be able to use the boat. """ + relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data + display_name = "Shuffle Boat" -class ShuffleDiscardedPanels(Toggle): +class ShuffleDiscardedPanels(RelevanceMixin, Toggle): """ Adds Discarded Panels into the location pool. Even if this is off, solving certain Discarded Panels may still be necessary to beat the game - The main example of this being the alternate activation triggers in "Disable non randomized puzzles". """ + relevance = OptionRelevance.all_trackers # Client gets this info through existing locations + display_name = "Shuffle Discarded Panels" -class ShuffleVaultBoxes(Toggle): +class ShuffleVaultBoxes(RelevanceMixin, Toggle): """ Adds Vault Boxes to the location pool. """ + relevance = OptionRelevance.all_trackers # Client gets this info through existing locations + display_name = "Shuffle Vault Boxes" -class ShuffleEnvironmentalPuzzles(Choice): +class ShuffleEnvironmentalPuzzles(RelevanceMixin, Choice): """ Adds Environmental/Obelisk Puzzles into the location pool. - Individual: Every Environmental Puzzle sends an item. @@ -210,13 +255,15 @@ class ShuffleEnvironmentalPuzzles(Choice): Note: In Obelisk Sides, any EPs excluded through another option will be pre-completed on their Obelisk. """ + relevance = OptionRelevance.all # Client currently uses this info, but could probably infer from existing locations + display_name = "Shuffle Environmental Puzzles" option_off = 0 option_individual = 1 option_obelisk_sides = 2 -class EasterEggHunt(Choice): +class EasterEggHunt(RelevanceMixin, Choice): """ Adds up to 120 Easter Eggs to the game, placed by NewSoupVi, Exempt-Medic, hatkirby, Scipio, and Rever. These can be collected by simply clicking on them. @@ -238,6 +285,7 @@ class EasterEggHunt(Choice): It is recommended that you play this mode together with Door Shuffle. Without it, more than half of the Easter Eggs will be in sphere 1. """ + relevance = OptionRelevance.all visibility = Visibility.all if is_easter_time() else Visibility.none @@ -263,11 +311,13 @@ class EasterEggHunt(Choice): return 4, 4 -class ShuffleDog(Choice): +class ShuffleDog(RelevanceMixin, Choice): """ Adds petting the dog statue in Town into the location pool. Alternatively, you can force it to be a Puzzle Skip. """ + relevance = OptionRelevance.all_trackers # Client infers this from existing locations + display_name = "Pet the Dog" option_off = 0 @@ -276,47 +326,55 @@ class ShuffleDog(Choice): default = 1 -class EnvironmentalPuzzlesDifficulty(Choice): +class EnvironmentalPuzzlesDifficulty(RelevanceMixin, Choice): """ When "Shuffle Environmental Puzzles" is on, this setting governs which EPs are eligible for the location pool. - Eclipse: Every EP in the game is eligible, including the 1-hour-long "Theater Eclipse EP". - Tedious: Theater Eclipse EP is excluded from the location pool. - Normal: several other difficult or long EPs are excluded as well. """ + relevance = OptionRelevance.all_trackers # Client just sees "disabled EPs" + display_name = "Environmental Puzzles Difficulty" option_normal = 0 option_tedious = 1 option_eclipse = 2 -class ObeliskKeys(DefaultOnToggle): +class ObeliskKeys(RelevanceMixin, DefaultOnToggle): """ Add one Obelisk Key item per Obelisk, locking you out of solving any of the associated Environmental Puzzles. Does nothing if "Shuffle Environmental Puzzles" is set to "off". """ + relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data + display_name = "Obelisk Keys" -class UnlockableWarps(Toggle): +class UnlockableWarps(RelevanceMixin, Toggle): """ Adds unlockable fast travel points to the game. These warp points are represented by spheres in game. You walk up to one, you unlock it for warping. The warp points are: Entry, Symmetry Island, Desert, Quarry, Keep, Shipwreck, Town, Jungle, Bunker, Treehouse, Mountaintop, Caves. """ + relevance = OptionRelevance.client | OptionRelevance.external_tracker # The PopTracker pack has a warp-based trick + display_name = "Unlockable Fast Travel Points" -class ShufflePostgame(Toggle): +class ShufflePostgame(RelevanceMixin, Toggle): """ Adds locations into the pool that are guaranteed to become accessible after or at the same time as your goal. Use this if you don't play with release on victory. """ + relevance = OptionRelevance.all + display_name = "Shuffle Postgame" -class VictoryCondition(Choice): +class VictoryCondition(RelevanceMixin, Choice): """ Set the victory condition for this world. - Elevator: Start the elevator at the bottom of the mountain (requires Mountain Lasers). @@ -328,6 +386,8 @@ class VictoryCondition(Choice): 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. """ + relevance = OptionRelevance.all_trackers # Client gets a "victory location" through slot_data instead + display_name = "Victory Condition" option_elevator = 0 option_challenge = 1 @@ -336,31 +396,35 @@ class VictoryCondition(Choice): option_panel_hunt = 4 -class PanelHuntTotal(Range): +class PanelHuntTotal(RelevanceMixin, Range): """ Only relevant if the Victory Condition is "Panel Hunt". Sets the number of random panels that will get marked as "Panel Hunt" panels in the "Panel Hunt" game mode. """ + relevance = OptionRelevance.none # slot_data contains a list of hunt panels, which client just checks the size of + display_name = "Total Panel Hunt panels" range_start = 5 range_end = 100 default = 40 -class PanelHuntRequiredPercentage(Range): +class PanelHuntRequiredPercentage(RelevanceMixin, Range): """ Only relevant if the Victory Condition is "Panel Hunt". Determines the percentage of "Panel Hunt" panels that need to be solved to win. """ + relevance = OptionRelevance.none # Tracked by a different value called WitnessWorld.panel_hunt_required_count + display_name = "Percentage of required Panel Hunt panels" range_start = 20 range_end = 100 default = 63 -class PanelHuntPostgame(Choice): +class PanelHuntPostgame(RelevanceMixin, Choice): """ Only relevant if the Victory Condition is "Panel Hunt". @@ -372,6 +436,7 @@ class PanelHuntPostgame(Choice): 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. """ + relevance = OptionRelevance.all display_name = "Force postgame in Panel Hunt" @@ -382,7 +447,7 @@ class PanelHuntPostgame(Choice): default = 3 -class PanelHuntDiscourageSameAreaFactor(Range): +class PanelHuntDiscourageSameAreaFactor(RelevanceMixin, Range): """ Only relevant if the Victory Condition is "Panel Hunt". @@ -391,6 +456,8 @@ class PanelHuntDiscourageSameAreaFactor(Range): At 0, Hunt Panels will be selected randomly. At 100, Hunt Panels will be almost completely evenly distributed between areas. """ + relevance = OptionRelevance.none # Universal tracker & client just consume the list of chosen hunt panels + display_name = "Panel Hunt Discourage Same Area Factor" range_start = 0 @@ -398,19 +465,20 @@ class PanelHuntDiscourageSameAreaFactor(Range): default = 40 -class PanelHuntPlando(LocationSet): +class PanelHuntPlando(RelevanceMixin, LocationSet): """ Only relevant if the Victory Condition is "Panel Hunt". Specify specific hunt panels you want for your panel hunt game. """ + relevance = OptionRelevance.none # Universal tracker & client just consume the list of chosen hunt panels display_name = "Panel Hunt Plando" valid_keys = [static_witness_logic.ENTITIES_BY_HEX[panel_hex]["checkName"] for panel_hex in ALL_HUNTABLE_PANELS] -class PuzzleRandomization(Choice): +class PuzzleRandomization(RelevanceMixin, Choice): """ Puzzles in this randomizer are randomly generated. This option changes the difficulty/types of puzzles. "Sigma Normal" randomizes puzzles close to their original mechanics and difficulty. @@ -418,6 +486,8 @@ class PuzzleRandomization(Choice): "Umbra Variety" focuses on unique symbol combinations not featured in the original game. It is harder than Sigma Normal, but easier than Sigma Expert. "None" means that the puzzles are unchanged from the original game. """ + relevance = OptionRelevance.all + display_name = "Puzzle Randomization" option_sigma_normal = 0 option_sigma_expert = 1 @@ -425,29 +495,33 @@ class PuzzleRandomization(Choice): option_none = 2 -class MountainLasers(Range): +class MountainLasers(RelevanceMixin, Range): """ Sets the number of lasers required to enter the Mountain. If set to a higher number than 7, the mountaintop box will be slightly rotated to make it possible to solve without the hatch being opened. This change will also be applied logically to the long solution ("Challenge Lasers" option). """ + relevance = OptionRelevance.all + display_name = "Required Lasers for Mountain Entry" range_start = 1 range_end = 11 default = 7 -class ChallengeLasers(Range): +class ChallengeLasers(RelevanceMixin, Range): """ Sets the number of lasers required to enter the Caves through the Mountain Bottom Floor Discard and to unlock the Challenge Timer Panel. """ + relevance = OptionRelevance.all + display_name = "Required Lasers for Challenge" range_start = 1 range_end = 11 default = 11 -class ElevatorsComeToYou(OptionSet): +class ElevatorsComeToYou(RelevanceMixin, OptionSet): """ In vanilla, some bridges/elevators come to you if you walk up to them when they are not currently there. However, there are some that don't. Notably, this prevents Quarry Elevator from being a logical access method into Quarry, because you can send it away without riding ot and then permanently be locked out of using it. @@ -459,6 +533,8 @@ class ElevatorsComeToYou(OptionSet): - Bunker Elevator: Makes the Bunker Elevator come to any floor that you approach it from, meaning it can be accessed from the roof immediately """ + relevance = OptionRelevance.all + # Used to be a toggle @classmethod def from_text(cls, text: str): @@ -476,10 +552,12 @@ class ElevatorsComeToYou(OptionSet): default = frozenset({"Quarry Elevator"}) -class TrapPercentage(Range): +class TrapPercentage(RelevanceMixin, Range): """ Replaces junk items with traps, at the specified rate. """ + relevance = OptionRelevance.none + display_name = "Trap Percentage" range_start = 0 range_end = 100 @@ -493,12 +571,14 @@ _default_trap_weights = { } -class TrapWeights(OptionCounter): +class TrapWeights(RelevanceMixin, OptionCounter): """ Specify the weights determining how many copies of each trap item will be in your itempool. If you don't want a specific type of trap, you can set the weight for it to 0. If you set all trap weights to 0, you will get no traps, bypassing the "Trap Percentage" option. """ + relevance = OptionRelevance.none + display_name = "Trap Weights" valid_keys = _default_trap_weights.keys() @@ -507,28 +587,32 @@ class TrapWeights(OptionCounter): default = _default_trap_weights -class PuzzleSkipAmount(Range): +class PuzzleSkipAmount(RelevanceMixin, Range): """ Adds this many Puzzle Skips into the pool, if there is room. Puzzle Skips let you skip one panel. """ + relevance = OptionRelevance.none + display_name = "Puzzle Skips" range_start = 0 range_end = 30 default = 10 -class HintAmount(Range): +class HintAmount(RelevanceMixin, Range): """ Adds hints to Audio Logs. If set to a low amount, up to 2 additional duplicates of each hint will be added. Remaining Audio Logs will have junk hints. """ + relevance = OptionRelevance.none + display_name = "Hints on Audio Logs" range_start = 0 range_end = 49 default = 12 -class VagueHints(Choice): +class VagueHints(RelevanceMixin, Choice): """Make Location Hints a bit more vague, where they only tell you about the general area the item is in. Area Hints will be generated as normal. @@ -539,6 +623,10 @@ class VagueHints(Choice): Also, please don't pester any devs about implementing location groups. Bring it up nicely, accept their response even if it is "No". """ + # This is to provide backcompat for a bug. + # Remove this when backcompat is broken for another reason + relevance = OptionRelevance.client + display_name = "Vague Hints" option_off = 0 @@ -546,51 +634,61 @@ class VagueHints(Choice): option_experimental = 2 -class AreaHintPercentage(Range): +class AreaHintPercentage(RelevanceMixin, Range): """ There are two types of hints for The Witness. "Location hints" hint one location in your world or one location containing an item for your world. "Area hints" tell you some general info about the items you can find in one of the main geographic areas on the island. Use this option to specify how many of your hints you want to be area hints. The rest will be location hints. """ + relevance = OptionRelevance.none + display_name = "Area Hint Percentage" range_start = 0 range_end = 100 default = 33 -class LaserHints(Toggle): +class LaserHints(RelevanceMixin, Toggle): """ If on, lasers will tell you where their items are if you walk close to them in-game. Only applies if Laser Shuffle is enabled. """ + relevance = OptionRelevance.none + display_name = "Laser Hints" -class DeathLink(Toggle): +class DeathLink(RelevanceMixin, Toggle): """ If on, whenever you fail a puzzle (with some exceptions), you and everyone who is also on Death Link dies. The effect of a "death" in The Witness is a Bonk Trap. """ + relevance = OptionRelevance.all # Trackers use this for trick logic (some tricks are unreasonable on death link) + display_name = "Death Link" -class DeathLinkAmnesty(Range): +class DeathLinkAmnesty(RelevanceMixin, Range): """ The number of panel fails to allow before sending a death through Death Link. 0 means every panel fail will send a death, 1 means every other panel fail will send a death, etc. """ + relevance = OptionRelevance.client + display_name = "Death Link Amnesty" range_start = 0 range_end = 5 default = 1 -class PuzzleRandomizationSeed(Range): +class PuzzleRandomizationSeed(RelevanceMixin, Range): """ Sigma Rando, which is the basis for all puzzle randomization in this randomizer, uses a seed from 1 to 9999999 for the puzzle randomization. This option lets you set this seed yourself. """ + relevance = OptionRelevance.client + display_name = "Puzzle Randomization Seed" range_start = 1 range_end = 9999999 @@ -644,6 +742,16 @@ class TheWitnessOptions(PerGameCommonOptions): early_symbol_item: Removed +assert all( + ( + issubclass(option_type, RelevanceMixin) + or option_name in PerGameCommonOptions.type_hints + or option_type is Removed + ) + for option_name, option_type in TheWitnessOptions.type_hints.items() +) + + witness_option_groups = [ OptionGroup("Puzzles & Goal", [ PuzzleRandomization, @@ -714,3 +822,4 @@ if is_easter_time(): else: silly_options_group = next(group for group in witness_option_groups if group.name == "Silly Options") silly_options_group.options.append(EasterEggHunt) + diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index c369ec623aa..bce11144df2 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -25,6 +25,7 @@ from .data.item_definition_classes import DoorItemDefinition, ItemCategory, Prog from .data.settings.progressive_items import PROGRESSIVE_SYMBOLS from .data.static_logic import StaticWitnessLogicObj from .data.utils import ( + entity_id_int_to_canonical_entity_id_string, get_boat, get_caves_except_path_to_challenge_exclusion_list, get_complex_additional_panels, @@ -61,7 +62,12 @@ class WitnessPlayerLogic: VICTORY_LOCATION: str - def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None: + 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 @@ -152,6 +158,8 @@ class WitnessPlayerLogic: # This will make the access conditions way faster, instead of recursively checking dependent entities each time. self.make_dependency_reduced_checklist() + self.make_universal_tracker_adjustments(world) + 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) @@ -1177,3 +1185,28 @@ class WitnessPlayerLogic: self.EVENT_ITEM_PAIRS[f"{region_name} Easter Egg{plural}"] = (event_name, region_name) return + + def make_universal_tracker_adjustments(self, world: "WitnessWorld") -> None: + re_gen_passthrough = getattr(world.multiworld, "re_gen_passthrough", None) + if re_gen_passthrough is None: + return + + witness_slot_data = re_gen_passthrough["The Witness"] + + if "hunt_entities" not in witness_slot_data: + raise ValueError( + "The Witness apworld you have is not compatible with this multiworld, " + "as it is missing 'hunt_entities' in slot_data." + ) + + self.PRE_PICKED_HUNT_ENTITIES = { + entity_id_int_to_canonical_entity_id_string(entity_int) for entity_int in witness_slot_data["hunt_entities"] + } + + not_real_entities = self.PRE_PICKED_HUNT_ENTITIES - set(self.REFERENCE_LOGIC.ENTITIES_BY_HEX) + if not_real_entities: + raise ValueError( + "UT slot_data had panel hunt entities in it that the apworld does not recognize. " + "Please report this to NewSoupVi.\n" + f"{not_real_entities}" + ) diff --git a/worlds/witness/universal_tracker.py b/worlds/witness/universal_tracker.py new file mode 100644 index 00000000000..229271335df --- /dev/null +++ b/worlds/witness/universal_tracker.py @@ -0,0 +1,48 @@ +from typing import TYPE_CHECKING, cast + +from Options import Option +from .options import OptionRelevance + +from . import RelevanceMixin, TheWitnessOptions + +if TYPE_CHECKING: + from . import WitnessWorld + + +def set_options_from_ut(world: "WitnessWorld"): + re_gen_passthrough = getattr(world.multiworld, "re_gen_passthrough", None) + if re_gen_passthrough is None: + return + + if not "The Witness" in re_gen_passthrough: + raise ValueError( + "Found Universal Tracker re_gen_passthrough, but \"The Witness\" key was not in it. " + "Something went terribly wrong here." + ) + + witness_slot_data = re_gen_passthrough["The Witness"] + + for option_name, option_type in TheWitnessOptions.type_hints.items(): + if not issubclass(option_type, RelevanceMixin): + continue + + option_class = cast(type[Option], option_type) + + if not option_type.relevance & OptionRelevance.universal_tracker_regeneration: + continue + + if option_name not in witness_slot_data: + raise ValueError( + "The Witness apworld you have is incompatible with this multiworld, " + f"as {option_name} is missing from slot data." + ) + + setattr(world.options, option_name, option_class.from_any(witness_slot_data[option_name])) + + if "panel_hunt_required_absolute" not in witness_slot_data: + raise ValueError( + "The Witness apworld you have is incompatible with this multiworld, " + "as panel_hunt_required_absolute is missing from slot data." + ) + + world.panel_hunt_required_count = witness_slot_data["panel_hunt_required_absolute"] \ No newline at end of file