From 3c6d1f1cc9b4bbf091c93a552d6094b374f2b95c Mon Sep 17 00:00:00 2001 From: Cuphat Date: Fri, 10 Feb 2023 20:23:00 -0500 Subject: [PATCH] Type Fixes, More Cleanup --- Colors.py | 4 +- Cosmetics.py | 4 +- Entrance.py | 12 +++- EntranceShuffle.py | 3 +- Goals.py | 19 +++-- HintList.py | 4 +- Hints.py | 16 +++-- IconManip.py | 8 ++- Item.py | 40 +++++++---- ItemList.py | 2 +- ItemPool.py | 26 ++++--- Location.py | 22 ++++-- LocationList.py | 6 +- Main.py | 17 ++--- Models.py | 2 +- Music.py | 2 +- N64Patch.py | 2 +- OcarinaSongs.py | 7 +- OoTRandomizer.py | 7 -- Patches.py | 20 ++++-- Plandomizer.py | 9 +-- Region.py | 8 +-- Rom.py | 145 +++++++++++++++++++------------------ RuleParser.py | 16 ++--- RulesCommon.py | 5 +- SaveContext.py | 9 ++- SceneFlags.py | 5 +- Search.py | 23 ++++-- SettingTypes.py | 161 +++++++++++++++++++++++++++++++----------- Settings.py | 9 +-- SettingsList.py | 2 +- SettingsListTricks.py | 5 +- Sounds.py | 7 +- Spoiler.py | 11 +-- State.py | 13 ++-- Unittest.py | 39 ++++++---- Utils.py | 26 ++----- World.py | 8 +-- ntype.py | 8 +-- 39 files changed, 455 insertions(+), 277 deletions(-) diff --git a/Colors.py b/Colors.py index 805997f9c..e27489612 100644 --- a/Colors.py +++ b/Colors.py @@ -208,7 +208,7 @@ } # C Button Pause Menu C Cursor Pause Menu C Icon C Note -c_button_colors: Dict[str, Color] = { +c_button_colors: Dict[str, Tuple[Color, Color, Color, Color]] = { "N64 Blue": (Color(0x5A, 0x5A, 0xFF), Color(0x00, 0x32, 0xFF), Color(0x00, 0x64, 0xFF), Color(0x50, 0x96, 0xFF)), "N64 Green": (Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00)), "N64 Red": (Color(0xC8, 0x00, 0x00), Color(0xC8, 0x00, 0x00), Color(0xC8, 0x00, 0x00), Color(0xC8, 0x00, 0x00)), @@ -378,7 +378,7 @@ def relative_luminance(color: List[int]) -> float: return color_ratios[0] * 0.299 + color_ratios[1] * 0.587 + color_ratios[2] * 0.114 -def lum_color_ratio(val: int) -> float: +def lum_color_ratio(val: float) -> float: val /= 255 if val <= 0.03928: return val / 12.92 diff --git a/Cosmetics.py b/Cosmetics.py index 749a1012c..90ec687b6 100644 --- a/Cosmetics.py +++ b/Cosmetics.py @@ -33,7 +33,6 @@ def patch_dpad(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: D rom.write_byte(symbols['CFG_DISPLAY_DPAD'], 0x01) else: rom.write_byte(symbols['CFG_DISPLAY_DPAD'], 0x00) - log.display_dpad = settings.display_dpad def patch_dpad_info(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: @@ -42,7 +41,6 @@ def patch_dpad_info(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbo rom.write_byte(symbols['CFG_DPAD_DUNGEON_INFO_ENABLE'], 0x01) else: rom.write_byte(symbols['CFG_DPAD_DUNGEON_INFO_ENABLE'], 0x00) - log.dpad_dungeon_menu = settings.dpad_dungeon_menu def patch_music(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: @@ -954,7 +952,7 @@ def patch_music_changes(rom, settings, log, symbols): ] patch_sets: Dict[int, Dict[str, Any]] = {} -global_patch_sets: List[Callable[["Rom", "Settings", 'CosmeticsLog', Dict[str, int]], type(None)]] = [ +global_patch_sets: List[Callable[["Rom", "Settings", 'CosmeticsLog', Dict[str, int]], None]] = [ patch_targeting, patch_music, patch_tunic_colors, diff --git a/Entrance.py b/Entrance.py index a790e755e..ad19d2ea9 100644 --- a/Entrance.py +++ b/Entrance.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List, Optional, Callable, Dict, Any +from typing import TYPE_CHECKING, List, Optional, Dict, Any from RulesCommon import AccessRule @@ -11,7 +11,7 @@ class Entrance: def __init__(self, name: str = '', parent: "Optional[Region]" = None) -> None: self.name: str = name self.parent_region: "Optional[Region]" = parent - self.world: "World" = parent.world + self.world: "Optional[World]" = parent.world if parent is not None else None self.connected_region: "Optional[Region]" = None self.access_rule: AccessRule = lambda state, **kwargs: True self.access_rules: List[AccessRule] = [] @@ -28,7 +28,7 @@ def __init__(self, name: str = '', parent: "Optional[Region]" = None) -> None: def copy(self, new_region: "Region") -> 'Entrance': new_entrance = Entrance(self.name, new_region) - new_entrance.connected_region = self.connected_region.name + new_entrance.connected_region = self.connected_region.name # TODO: Revamp World/Region copying such that this is not a type error. new_entrance.access_rule = self.access_rule new_entrance.access_rules = list(self.access_rules) new_entrance.reverse = self.reverse @@ -62,6 +62,8 @@ def connect(self, region: "Region") -> None: region.entrances.append(self) def disconnect(self) -> "Optional[Region]": + if self.connected_region is None: + raise Exception(f"`disconnect()` called without a valid `connected_region` for entrance {self.name}.") self.connected_region.entrances.remove(self) previously_connected = self.connected_region self.connected_region = None @@ -72,6 +74,10 @@ def bind_two_way(self, other_entrance: 'Entrance') -> None: other_entrance.reverse = self def get_new_target(self) -> 'Entrance': + if self.world is None: + raise Exception(f"`get_new_target()` called without a valid `world` for entrance {self.name}.") + if self.connected_region is None: + raise Exception(f"`get_new_target()` called without a valid `connected_region` for entrance {self.name}.") root = self.world.get_region('Root Exits') target_entrance = Entrance('Root -> ' + self.connected_region.name, root) target_entrance.connect(self.connected_region) diff --git a/EntranceShuffle.py b/EntranceShuffle.py index cc4855964..882d5ba62 100644 --- a/EntranceShuffle.py +++ b/EntranceShuffle.py @@ -45,7 +45,7 @@ def assume_entrance_pool(entrance_pool: "List[Entrance]") -> "List[Entrance]": if entrance.reverse is not None: assumed_return = entrance.reverse.assume_reachable() if (entrance.type in ('Dungeon', 'Grotto', 'Grave') and entrance.reverse.name != 'Spirit Temple Lobby -> Desert Colossus From Spirit Lobby') or \ - (entrance.type == 'Interior' and entrance.world.shuffle_special_interior_entrances): + (entrance.type == 'Interior' and entrance.world and entrance.world.shuffle_special_interior_entrances): # In most cases, Dungeon, Grotto/Grave and Simple Interior exits shouldn't be assumed able to give access to their parent region assumed_return.set_rule(lambda state, **kwargs: False) assumed_forward.bind_two_way(assumed_return) @@ -444,6 +444,7 @@ def shuffle_random_entrances(worlds: "List[World]") -> None: non_drop_locations = [location for world in worlds for location in world.get_locations() if location.type not in ('Drop', 'Event')] max_search.visit_locations(non_drop_locations) locations_to_ensure_reachable = list(filter(max_search.visited, non_drop_locations)) + placed_one_way_entrances = None # Shuffle all entrances within their own worlds for world in worlds: diff --git a/Goals.py b/Goals.py index 45e9d5725..c88f76387 100644 --- a/Goals.py +++ b/Goals.py @@ -1,10 +1,16 @@ +import sys from collections import defaultdict from typing import TYPE_CHECKING, List, Union, Dict, Optional, Any, Tuple, Iterable, Callable, Collection from HintList import goalTable, get_hint_group, hint_exclusions from ItemList import item_table +from RulesCommon import AccessRule from Search import Search, ValidGoals -from Utils import TypeAlias + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str if TYPE_CHECKING: from Location import Location @@ -274,7 +280,7 @@ def update_goal_items(spoiler: "Spoiler") -> None: spoiler.goal_locations = required_locations_dict -def lock_category_entrances(category: GoalCategory, state_list: "Iterable[State]") -> "Dict[int, Dict[str, Callable[[State, ...], bool]]]": +def lock_category_entrances(category: GoalCategory, state_list: "Iterable[State]") -> "Dict[int, Dict[str, AccessRule]]": # Disable access rules for specified entrances category_locks = {} if category.lock_entrances is not None: @@ -287,7 +293,7 @@ def lock_category_entrances(category: GoalCategory, state_list: "Iterable[State] return category_locks -def unlock_category_entrances(category_locks: "Dict[int, Dict[str, Callable[[State, ...], bool]]]", +def unlock_category_entrances(category_locks: "Dict[int, Dict[str, AccessRule]]", state_list: "List[State]") -> None: # Restore access rules for state_id, exits in category_locks.items(): @@ -345,10 +351,11 @@ def search_goals(categories: Dict[str, GoalCategory], reachable_goals: ValidGoal if search_woth and not valid_goals['way of the hero']: required_locations['way of the hero'].append(location) location.item = old_item - location.maybe_set_misc_item_hints() + location.maybe_set_misc_hints() remaining_locations.remove(location) - search.state_list[location.item.world.id].collect(location.item) + if location.item.solver_id is not None: + search.state_list[location.item.world.id].collect(location.item) for location in remaining_locations: # finally, collect unreachable locations for misc. item hints - location.maybe_set_misc_item_hints() + location.maybe_set_misc_hints() return required_locations diff --git a/HintList.py b/HintList.py index a19aaa5ec..3fc787328 100644 --- a/HintList.py +++ b/HintList.py @@ -26,7 +26,7 @@ class Hint: - def __init__(self, name: str, text: Union[str, List[str]], hint_type: Union[str, List[str]], choice: int = None) -> None: + def __init__(self, name: str, text: Union[str, List[str]], hint_type: Union[str, List[str]], choice: Optional[int] = None) -> None: self.name: str = name self.type: List[str] = [hint_type] if not isinstance(hint_type, list) else hint_type @@ -290,7 +290,7 @@ def tokens_required_by_settings(world: "World") -> int: # \u00A9 Down arrow # \u00AA Joystick -hintTable: Dict[str, Tuple[List[str], Optional[str], Union[str, List[str]]]] = { +hintTable: Dict[str, Tuple[Union[List[str], str], Optional[str], Union[str, List[str]]]] = { 'Kokiri Emerald': (["a tree's farewell", "the Spiritual Stone of the Forest"], "the Kokiri Emerald", 'item'), 'Goron Ruby': (["the Gorons' hidden treasure", "the Spiritual Stone of Fire"], "the Goron Ruby", 'item'), 'Zora Sapphire': (["an engagement ring", "the Spiritual Stone of Water"], "the Zora Sapphire", 'item'), diff --git a/Hints.py b/Hints.py index 0c72769cf..2aa762d6b 100644 --- a/Hints.py +++ b/Hints.py @@ -3,6 +3,7 @@ import logging import os import random +import sys import urllib.request from collections import OrderedDict, defaultdict from enum import Enum @@ -16,7 +17,12 @@ from Region import Region from Search import Search from TextBox import line_wrap -from Utils import TypeAlias, data_path +from Utils import data_path + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str if TYPE_CHECKING: from Entrance import Entrance @@ -27,8 +33,8 @@ Spot: TypeAlias = "Union[Entrance, Location, Region]" HintReturn: TypeAlias = "Optional[Tuple[GossipText, Optional[List[Location]]]]" -HintFunc: TypeAlias = "Callable[[Spoiler, World, MutableSet[str]], HintReturn]" -BarrenFunc: TypeAlias = "Callable[[Spoiler, World, MutableSet[str], MutableSet[str]], HintReturn]" +HintFunc: TypeAlias = Callable[["Spoiler", "World", MutableSet[str]], HintReturn] +BarrenFunc: TypeAlias = Callable[["Spoiler", "World", MutableSet[str], MutableSet[str]], HintReturn] bingoBottlesForHints: Set[str] = { "Bottle", "Bottle with Red Potion", "Bottle with Green Potion", "Bottle with Blue Potion", @@ -467,8 +473,8 @@ def is_dungeon_item(self, item: Item) -> bool: # Formats the hint text for this area with proper grammar. # Dungeons are hinted differently depending on the clearer_hints setting. - def text(self, clearer_hints: bool, preposition: bool = False, world: "Optional[World]" = None) -> str: - if self.is_dungeon: + def text(self, clearer_hints: bool, preposition: bool = False, world: "Optional[int]" = None) -> str: + if self.is_dungeon and self.dungeon_name: text = get_hint(self.dungeon_name, clearer_hints).text else: text = str(self) diff --git a/IconManip.py b/IconManip.py index 7159038dd..63fffb359 100644 --- a/IconManip.py +++ b/IconManip.py @@ -1,6 +1,12 @@ +import sys from typing import TYPE_CHECKING, Sequence, MutableSequence, Optional -from Utils import data_path, TypeAlias +from Utils import data_path + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str if TYPE_CHECKING: from Rom import Rom diff --git a/Item.py b/Item.py index b368cce38..0a8992017 100644 --- a/Item.py +++ b/Item.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Tuple, List, Dict, Union, Iterable, Set, Any, Callable +from typing import TYPE_CHECKING, Optional, Tuple, List, Dict, Union, Iterable, Set, Any, Callable, overload from ItemList import item_table from RulesCommon import allowed_globals, escape_name @@ -14,7 +14,7 @@ class ItemInfo: bottles: Set[str] = set() medallions: Set[str] = set() stones: Set[str] = set() - junk: Dict[str, int] = {} + junk_weight: Dict[str, int] = {} solver_ids: Dict[str, int] = {} bottle_ids: Set[int] = set() @@ -44,7 +44,7 @@ def __init__(self, name: str = '', event: bool = False) -> None: self.junk: Optional[int] = self.special.get('junk', None) self.trade: bool = self.special.get('trade', False) - self.solver_id = None + self.solver_id: Optional[int] = None if name and self.junk is None: esc = escape_name(name) if esc not in ItemInfo.solver_ids: @@ -53,18 +53,18 @@ def __init__(self, name: str = '', event: bool = False) -> None: for item_name in item_table: - ItemInfo.items[item_name] = ItemInfo(item_name) - if ItemInfo.items[item_name].bottle: + iteminfo = ItemInfo.items[item_name] = ItemInfo(item_name) + if iteminfo.bottle: ItemInfo.bottles.add(item_name) ItemInfo.bottle_ids.add(ItemInfo.solver_ids[escape_name(item_name)]) - if ItemInfo.items[item_name].medallion: + if iteminfo.medallion: ItemInfo.medallions.add(item_name) ItemInfo.medallion_ids.add(ItemInfo.solver_ids[escape_name(item_name)]) - if ItemInfo.items[item_name].stone: + if iteminfo.stone: ItemInfo.stones.add(item_name) ItemInfo.stone_ids.add(ItemInfo.solver_ids[escape_name(item_name)]) - if ItemInfo.items[item_name].junk is not None: - ItemInfo.junk[item_name] = ItemInfo.items[item_name].junk + if iteminfo.junk is not None: + ItemInfo.junk_weight[item_name] = iteminfo.junk class Item: @@ -79,7 +79,7 @@ def __init__(self, name: str = '', world: "Optional[World]" = None, event: bool else: self.info: ItemInfo = ItemInfo.items[name] self.price: Optional[int] = self.info.special.get('price', None) - self.world: "World" = world + self.world: "Optional[World]" = world self.looks_like_item: 'Optional[Item]' = None self.advancement: bool = self.info.advancement self.priority: bool = self.info.priority @@ -88,9 +88,9 @@ def __init__(self, name: str = '', world: "Optional[World]" = None, event: bool self.index: Optional[int] = self.info.index self.alias: Optional[Tuple[str, int]] = self.info.alias - self.solver_id = self.info.solver_id + self.solver_id: Optional[int] = self.info.solver_id # Do not alias to junk--it has no solver id! - self.alias_id = ItemInfo.solver_ids[escape_name(self.alias[0])] if self.alias else None + self.alias_id: Optional[int] = ItemInfo.solver_ids[escape_name(self.alias[0])] if self.alias else None item_worlds_to_fix: 'Dict[Item, int]' = {} @@ -141,6 +141,8 @@ def dungeonitem(self) -> bool: @property def unshuffled_dungeon_item(self) -> bool: + if self.world is None: + return False return ((self.type == 'SmallKey' and self.world.settings.shuffle_smallkeys in ('remove', 'vanilla', 'dungeon')) or (self.type == 'HideoutSmallKey' and self.world.settings.shuffle_hideoutkeys == 'vanilla') or (self.type == 'TCGSmallKey' and self.world.settings.shuffle_tcgkeys in ('remove', 'vanilla')) or @@ -151,6 +153,8 @@ def unshuffled_dungeon_item(self) -> bool: @property def majoritem(self) -> bool: + if self.world is None: + return False if self.type == 'Token': return (self.world.settings.bridge == 'tokens' or self.world.settings.shuffle_ganon_bosskey == 'tokens' or (self.world.settings.shuffle_ganon_bosskey == 'on_lacs' and self.world.settings.lacs_condition == 'tokens')) @@ -184,6 +188,8 @@ def majoritem(self) -> bool: @property def goalitem(self) -> bool: + if self.world is None: + return False return self.name in self.world.goal_items def __str__(self) -> str: @@ -193,6 +199,14 @@ def __unicode__(self) -> str: return '%s' % self.name +@overload +def ItemFactory(items: str, world: "Optional[World]" = None, event: bool = False) -> Item: + pass + +@overload +def ItemFactory(items: Iterable[str], world: "Optional[World]" = None, event: bool = False) -> List[Item]: + pass + def ItemFactory(items: Union[str, Iterable[str]], world: "Optional[World]" = None, event: bool = False) -> Union[Item, List[Item]]: if isinstance(items, str): if not event and items not in ItemInfo.items: @@ -209,6 +223,8 @@ def ItemFactory(items: Union[str, Iterable[str]], world: "Optional[World]" = Non def make_event_item(name: str, location: "Location", item: Optional[Item] = None) -> Item: + if location.world is None: + raise Exception(f"`make_event_item` called with location '{location.name}' that doesn't have a world.") if item is None: item = Item(name, location.world, event=True) location.world.push_item(location, item) diff --git a/ItemList.py b/ItemList.py index 80aa147c0..73ac0062a 100644 --- a/ItemList.py +++ b/ItemList.py @@ -4,7 +4,7 @@ # False -> Priority # None -> Normal # Item: (type, Progressive, GetItemID, special), -item_table: Dict[str, Tuple[str, Optional[bool], int, Dict[str, Any]]] = { +item_table: Dict[str, Tuple[str, Optional[bool], Optional[int], Optional[Dict[str, Any]]]] = { 'Bombs (5)': ('Item', None, 0x0001, {'junk': 8}), 'Deku Nuts (5)': ('Item', None, 0x0002, {'junk': 5}), 'Bombchus (10)': ('Item', True, 0x0003, None), diff --git a/ItemPool.py b/ItemPool.py index 2b3381f62..e39415bb6 100644 --- a/ItemPool.py +++ b/ItemPool.py @@ -304,9 +304,9 @@ ) normal_bottles: List[str] = [bottle for bottle in sorted(ItemInfo.bottles) if bottle not in ['Deliver Letter', 'Sell Big Poe']] + ['Bottle with Big Poe'] -song_list: List[str] = [item.name for item in sorted([i for n, i in ItemInfo.items.items() if i.type == 'Song'], key=lambda x: x.index)] -junk_pool_base: List[Tuple[str, int]] = [(item, weight) for (item, weight) in sorted(ItemInfo.junk.items()) if weight > 0] -remove_junk_items: List[str] = [item for (item, weight) in sorted(ItemInfo.junk.items()) if weight >= 0] +song_list: List[str] = [item.name for item in sorted([i for n, i in ItemInfo.items.items() if i.type == 'Song'], key=lambda x: x.index if x.index is not None else 0)] +junk_pool_base: List[Tuple[str, int]] = [(item, weight) for (item, weight) in sorted(ItemInfo.junk_weight.items()) if weight > 0] +remove_junk_items: List[str] = [item for (item, weight) in sorted(ItemInfo.junk_weight.items()) if weight >= 0] remove_junk_ludicrous_items: List[str] = [ 'Ice Arrows', @@ -375,15 +375,13 @@ def get_junk_item(count: int = 1, pool: Optional[List[str]] = None, plando_pool: count -= pending_count if pool and plando_pool: - jw_list = [(junk, weight) for (junk, weight) in junk_pool - if junk not in plando_pool or pool.count(junk) < plando_pool[junk].count] - try: - junk_items, junk_weights = zip(*jw_list) - except ValueError: + jw_dict = {junk: weight for (junk, weight) in junk_pool + if junk not in plando_pool or pool.count(junk) < plando_pool[junk].count} + if not jw_dict: raise RuntimeError("Not enough junk is available in the item pool to replace removed items.") else: - junk_items, junk_weights = zip(*junk_pool) - return_pool.extend(random.choices(junk_items, weights=junk_weights, k=count)) + jw_dict = {junk: weight for (junk, weight) in junk_pool} + return_pool.extend(random.choices(list(jw_dict.keys()), weights=list(jw_dict.values()), k=count)) return return_pool @@ -709,9 +707,9 @@ def get_pool_core(world: "World") -> Tuple[List[str], Dict[str, str]]: elif location.type in ['Pot', 'FlyingPot']: if world.settings.shuffle_pots == 'all': shuffle_item = True - elif world.settings.shuffle_pots == 'dungeons' and (location.dungeon is not None or location.parent_region.is_boss_room): + elif world.settings.shuffle_pots == 'dungeons' and (location.dungeon is not None or (location.parent_region is not None and location.parent_region.is_boss_room)): shuffle_item = True - elif world.settings.shuffle_pots == 'overworld' and not (location.dungeon is not None or location.parent_region.is_boss_room): + elif world.settings.shuffle_pots == 'overworld' and not (location.dungeon is not None or (location.parent_region is not None and location.parent_region.is_boss_room)): shuffle_item = True else: shuffle_item = False @@ -787,7 +785,7 @@ def get_pool_core(world: "World") -> Tuple[List[str], Dict[str, str]]: shuffle_item = True # Handle dungeon item. - if shuffle_setting is not None and not shuffle_item: + if shuffle_setting is not None and dungeon_collection is not None and not shuffle_item: dungeon_collection.append(ItemFactory(item)) if shuffle_setting in ['remove', 'startwith']: world.state.collect(dungeon_collection[-1]) @@ -880,7 +878,7 @@ def get_pool_core(world: "World") -> Tuple[List[str], Dict[str, str]]: if pending_junk_pool: for item in set(pending_junk_pool): # Ensure pending_junk_pool contents don't exceed values given by distribution file - if item in world.distribution.item_pool: + if world.distribution.item_pool and item in world.distribution.item_pool: while pending_junk_pool.count(item) > world.distribution.item_pool[item].count: pending_junk_pool.remove(item) # Remove pending junk already added to the pool by alter_pool from the pending_junk_pool diff --git a/Location.py b/Location.py index 421c354e1..e2ba0e147 100644 --- a/Location.py +++ b/Location.py @@ -1,6 +1,6 @@ import logging from enum import Enum -from typing import TYPE_CHECKING, Optional, List, Tuple, Callable, Union, Iterable +from typing import TYPE_CHECKING, Optional, List, Tuple, Callable, Union, Iterable, overload from HintList import misc_item_hint_table, misc_location_hint_table from LocationList import location_table, location_is_viewable, LocationAddress, LocationDefault, LocationFilterTags @@ -45,7 +45,7 @@ def __init__(self, name: str = '', address: LocationAddress = None, address2: Lo self.disabled: DisableType = DisableType.ENABLED self.always: bool = False self.never: bool = False - self.filter_tags: Tuple[str, ...] = (filter_tags,) if isinstance(filter_tags, str) else filter_tags + self.filter_tags: Optional[Tuple[str, ...]] = (filter_tags,) if isinstance(filter_tags, str) else filter_tags self.rule_string: Optional[str] = None def copy(self, new_region: "Region") -> 'Location': @@ -92,6 +92,8 @@ def set_rule(self, lambda_rule: AccessRule) -> None: self.access_rules = [lambda_rule] def can_fill(self, state: "State", item: "Item", check_access: bool = True) -> bool: + if state.search is None: + return False if self.minor_only and item.majoritem: return False return ( @@ -101,6 +103,8 @@ def can_fill(self, state: "State", item: "Item", check_access: bool = True) -> b ) def can_fill_fast(self, item: "Item", manual: bool = False) -> bool: + if self.parent_region is None: + return False return self.parent_region.can_fill(item, manual) and self.item_rule(self, item) @property @@ -111,6 +115,8 @@ def is_disabled(self) -> bool: # Can the player see what's placed at this location without collecting it? # Used to reduce JSON spoiler noise def has_preview(self) -> bool: + if self.world is None: + return False return location_is_viewable(self.name, self.world.settings.correct_chest_appearances, self.world.settings.fast_chests) def has_item(self) -> bool: @@ -122,8 +128,8 @@ def has_no_item(self) -> bool: def has_progression_item(self) -> bool: return self.item is not None and self.item.advancement - def maybe_set_misc_item_hints(self) -> None: - if not self.item: + def maybe_set_misc_hints(self) -> None: + if self.item is None or self.item.world is None or self.world is None: return if self.item.world.dungeon_rewards_hinted and self.item.name in self.item.world.rewardlist: if self.item.name not in self.item.world.hinted_dungeon_reward_locations: @@ -147,6 +153,14 @@ def __unicode__(self) -> str: return '%s' % self.name +@overload +def LocationFactory(locations: str) -> Location: + pass + +@overload +def LocationFactory(locations: List[str]) -> List[Location]: + pass + def LocationFactory(locations: Union[str, List[str]]) -> Union[Location, List[Location]]: ret = [] singleton = False diff --git a/LocationList.py b/LocationList.py index 7ff296011..f4ff2f867 100644 --- a/LocationList.py +++ b/LocationList.py @@ -1,7 +1,11 @@ +import sys from collections import OrderedDict from typing import Dict, Tuple, Optional, Union, List -from Utils import TypeAlias +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str LocationDefault: TypeAlias = Optional[Union[int, Tuple[int, ...], List[Tuple[int, ...]]]] LocationAddress: TypeAlias = Optional[Union[int, List[int]]] diff --git a/Main.py b/Main.py index f73601e10..f339875d4 100644 --- a/Main.py +++ b/Main.py @@ -5,10 +5,10 @@ import platform import random import shutil -import struct import time -from typing import List import zipfile +from typing import List, Optional + from Cosmetics import CosmeticsLog, patch_cosmetics from EntranceShuffle import set_entrances @@ -51,12 +51,14 @@ def main(settings: Settings, max_attempts: int = 10) -> Spoiler: else: logger.info('Retrying...\n\n') settings.reset_distribution() + if spoiler is None: + raise RuntimeError("Generation failed.") patch_and_output(settings, spoiler, rom) logger.debug('Total Time: %s', time.process_time() - start) return spoiler -def resolve_settings(settings: Settings) -> Rom: +def resolve_settings(settings: Settings) -> Optional[Rom]: logger = logging.getLogger('') old_tricks = settings.allowed_tricks @@ -185,7 +187,7 @@ def make_spoiler(settings: Settings, worlds: List[World]) -> Spoiler: return spoiler -def prepare_rom(spoiler: Spoiler, world: World, rom: Rom, settings: Settings, rng_state: tuple = None, restore: bool = True) -> CosmeticsLog: +def prepare_rom(spoiler: Spoiler, world: World, rom: Rom, settings: Settings, rng_state: Optional[tuple] = None, restore: bool = True) -> CosmeticsLog: if rng_state: random.setstate(rng_state) # Use different seeds for each world when patching. @@ -279,7 +281,7 @@ def generate_wad(wad_file: str, rom_file: str, output_file: str, channel_title: os.remove(rom_file) -def patch_and_output(settings: Settings, spoiler: Spoiler, rom: Rom) -> None: +def patch_and_output(settings: Settings, spoiler: Spoiler, rom: Optional[Rom]) -> None: logger = logging.getLogger('') worlds = spoiler.worlds cosmetics_log = None @@ -299,7 +301,7 @@ def patch_and_output(settings: Settings, spoiler: Spoiler, rom: Rom) -> None: generate_rom = uncompressed_rom or settings.create_patch_file or settings.patch_without_output separate_cosmetics = settings.create_patch_file and uncompressed_rom - if generate_rom: + if generate_rom and rom is not None: rng_state = random.getstate() file_list = [] restore_rom = False @@ -581,8 +583,7 @@ def diff_roms(settings: Settings, diff_rom_file: str) -> None: output_path = os.path.join(output_dir, output_filename_base) logger.info('Loading patched ROM.') - rom.read_rom(diff_rom_file) - rom.decompress_rom_file(diff_rom_file, f"{output_path}_decomp.z64", verify_crc=False) + rom.read_rom(diff_rom_file, f"{output_path}_decomp.z64", verify_crc=False) try: os.remove(f"{output_path}_decomp.z64") except FileNotFoundError: diff --git a/Models.py b/Models.py index be75fd435..d6e00d124 100644 --- a/Models.py +++ b/Models.py @@ -306,7 +306,7 @@ def LoadVanilla(rom: "Rom", missing: List[str], rebase: int, linkstart: int, lin # Branch to another texture if segJ == segment: if vanillaData[j+1] == 0x0: - returnStack.push(j) + returnStack.append(j) j = loJ & 0x00FFFFFF elif opJ == 0xF0: # F0: G_LOADTLUT diff --git a/Music.py b/Music.py index e2038ff4c..54da66884 100644 --- a/Music.py +++ b/Music.py @@ -209,7 +209,7 @@ def process_sequences(rom: Rom, ids: Iterable[Tuple[str, int]], seq_type: str = def shuffle_music(log: "CosmeticsLog", source_sequences: Dict[str, Sequence], target_sequences: Dict[str, Sequence], - music_mapping: Dict[str, Union[str, List[str]]], seq_type: str = "music") -> List[Sequence]: + music_mapping: Dict[str, str], seq_type: str = "music") -> List[Sequence]: sequences = [] favorites = log.src_dict.get('bgm_groups', {}).get('favorites', []).copy() diff --git a/N64Patch.py b/N64Patch.py index 61a4c498c..fc6f8927c 100644 --- a/N64Patch.py +++ b/N64Patch.py @@ -243,7 +243,7 @@ def apply_patch_file(rom: Rom, settings: "Settings", sub_file: Optional[str] = N rom.buffer[start:start+size] = [0] * size # Read in the XOR data blocks. This goes to the end of the file. - block_start = None + block_start = 0 while not patch_data.eof(): is_new_block = patch_data.read_byte() != 0xFF diff --git a/OcarinaSongs.py b/OcarinaSongs.py index 746e76e4f..b97e0aeab 100644 --- a/OcarinaSongs.py +++ b/OcarinaSongs.py @@ -1,9 +1,14 @@ import random +import sys from itertools import chain from typing import TYPE_CHECKING, Dict, List, Tuple, Sequence, Callable, Optional, Union from Fill import ShuffleError -from Utils import TypeAlias + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str if TYPE_CHECKING: from Rom import Rom diff --git a/OoTRandomizer.py b/OoTRandomizer.py index d3bbb005e..88c489b84 100755 --- a/OoTRandomizer.py +++ b/OoTRandomizer.py @@ -4,19 +4,12 @@ print("OoT Randomizer requires Python version 3.6 or newer and you are using %s" % '.'.join([str(i) for i in sys.version_info[0:3]])) sys.exit(1) -import argparse import datetime import logging import os -import textwrap import time -class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): - def _get_help_string(self, action): - return textwrap.dedent(action.help) - - def start() -> None: from Main import main, from_patch_file, cosmetic_patch, diff_roms from Settings import get_settings_from_command_line_args diff --git a/Patches.py b/Patches.py index b3d9e806e..6e2e76cc0 100644 --- a/Patches.py +++ b/Patches.py @@ -3,6 +3,7 @@ import random import re import struct +import sys import zlib from typing import Dict, List, Iterable, Tuple, Set, Callable, Optional, Any @@ -24,11 +25,16 @@ from SaveContext import SaveContext, Scenes, FlagType from SceneFlags import get_alt_list_bytes, get_collectible_flag_table, get_collectible_flag_table_bytes from Spoiler import Spoiler -from Utils import TypeAlias, data_path +from Utils import data_path from World import World from texture_util import ci4_rgba16patch_to_ci8, rgba16_patch from version import __version__ +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str + OverrideEntry: TypeAlias = Tuple[int, int, int, int, int, int] @@ -983,6 +989,8 @@ def set_entrance_updates(entrances: Iterable[Entrance]) -> None: rom.write_int16(0xAC995A, 0x060C) for entrance in entrances: + if entrance.data is None or entrance.replaces is None or entrance.replaces.data is None: + continue new_entrance = entrance.data replaced_entrance = (entrance.replaces or entrance).data @@ -1670,6 +1678,7 @@ def calculate_traded_flags(world): locations = [ loc for region in jabu_reward_regions + if region is not None and region.locations is not None for loc in region.locations if not loc.locked and loc.has_item() @@ -1687,11 +1696,12 @@ def calculate_traded_flags(world): jabu_reward_regions = { exit.connected_region for region in jabu_reward_regions + if region is not None for exit in region.exits - if exit.connected_region.dungeon != 'Jabu Jabus Belly' and exit.connected_region not in already_checked + if exit.connected_region is not None and exit.connected_region.dungeon != 'Jabu Jabus Belly' and exit.connected_region.name not in already_checked } - if location is None: + if location is None or location.item is None: jabu_item = None reward_text = None elif location.item.looks_like_item is not None: @@ -1702,7 +1712,7 @@ def calculate_traded_flags(world): reward_text = get_hint(get_item_generic_name(location.item), True).text # Update "Princess Ruto got the Spiritual Stone!" text before the midboss in Jabu - if reward_text is None: + if reward_text is None or location is None or location.item is None: new_message = f"\x08Princess Ruto got \x01\x05\x43nothing\x05\x40!\x01Well, that's disappointing...\x02" else: reward_texts = { @@ -1721,7 +1731,7 @@ def calculate_traded_flags(world): update_message_by_id(messages, 0x4050, new_message) # Set Dungeon Reward Actor in Jabu Jabu to be accurate - if location is not None: #TODO make actor invisible if no item? + if location is not None and location.item is not None: # TODO make actor invisible if no item? jabu_item = location.item jabu_actor_type = jabu_item.special.get('actor_type', 0x15) #TODO handle non-dungeon-reward items set_jabu_stone_actors(rom, jabu_actor_type) diff --git a/Plandomizer.py b/Plandomizer.py index 622f5cc7f..7669999d2 100644 --- a/Plandomizer.py +++ b/Plandomizer.py @@ -51,9 +51,10 @@ class InvalidFileException(Exception): class Record: - def __init__(self, properties: Dict[str, Any] = None, src_dict: Dict[str, Any] = None) -> None: + def __init__(self, properties: Optional[Dict[str, Any]] = None, src_dict: Optional[Dict[str, Any]] = None) -> None: self.properties: Dict[str, Any] = properties if properties is not None else getattr(self, "properties") - self.update(src_dict, update_all=True) + if src_dict is not None: + self.update(src_dict, update_all=True) def update(self, src_dict: Dict[str, Any], update_all: bool = False) -> None: if src_dict is None: @@ -117,13 +118,13 @@ def to_json(self) -> Dict[str, Any]: self.colors = CollapseList(self.colors) if self.hinted_locations is not None: self.hinted_locations = CollapseList(self.hinted_locations) - if self.hinted_locations is not None: + if self.hinted_items is not None: self.hinted_items = CollapseList(self.hinted_items) return CollapseDict(super().to_json()) class ItemPoolRecord(Record): - def __init__(self, src_dict: int = 1) -> None: + def __init__(self, src_dict: Union[int, Dict[str, int]] = 1) -> None: self.type: str = 'set' self.count: int = 1 diff --git a/Region.py b/Region.py index 050ca6303..187293579 100644 --- a/Region.py +++ b/Region.py @@ -67,7 +67,7 @@ def copy(self, new_world: "World") -> 'Region': return new_region @property - def hint(self) -> "HintArea": + def hint(self) -> "Optional[HintArea]": from Hints import HintArea if self.hint_name is not None: @@ -76,13 +76,15 @@ def hint(self) -> "HintArea": return self.dungeon.hint @property - def alt_hint(self) -> "HintArea": + def alt_hint(self) -> "Optional[HintArea]": from Hints import HintArea if self.alt_hint_name is not None: return HintArea[self.alt_hint_name] def can_fill(self, item: "Item", manual: bool = False) -> bool: + from Hints import HintArea + if not manual and self.world.settings.empty_dungeons_mode != 'none' and item.dungeonitem: # An empty dungeon can only store its own dungeon items if self.dungeon and self.dungeon.world.empty_dungeons[self.dungeon.name].empty: @@ -92,8 +94,6 @@ def can_fill(self, item: "Item", manual: bool = False) -> bool: if item.world.empty_dungeons[dungeon.name].empty and dungeon.is_dungeon_item(item): return False - from Hints import HintArea - is_self_dungeon_restricted = False is_self_region_restricted = None is_hint_color_restricted = None diff --git a/Rom.py b/Rom.py index 7e5e374e5..f26c478df 100644 --- a/Rom.py +++ b/Rom.py @@ -1,15 +1,14 @@ +import copy import json import os import platform -import struct import subprocess -import copy -from typing import List, Tuple, Sequence, Iterable, Optional +from typing import List, Tuple, Sequence, Iterator, Optional +from Models import restrictiveBytes from Utils import is_bundled, subprocess_args, local_path, data_path, get_version_bytes -from ntype import BigStream from crc import calculate_crc -from Models import restrictiveBytes +from ntype import BigStream from version import base_version, branch_identifier, supplementary_version DMADATA_START: int = 0x7430 # NTSC 1.0/1.1: 0x7430, NTSC 1.2: 0x7960, Debug: 0x012F70 @@ -17,13 +16,14 @@ class Rom(BigStream): - def __init__(self, file: str = None) -> None: + def __init__(self, file: Optional[str] = None) -> None: super().__init__(bytearray()) - self.original: Optional[Rom] = None + self.original: Rom = self self.changed_address: dict[int, int] = {} self.changed_dma: dict[int, Tuple[int, int, int]] = {} self.force_patch: List[int] = [] + self.dma: 'DMAIterator' = DMAIterator(self, DMADATA_START, DMADATA_INDEX) if file is None: return @@ -36,27 +36,20 @@ def __init__(self, file: str = None) -> None: symbols = json.load(stream) self.symbols: dict[str, int] = {name: int(addr, 16) for name, addr in symbols.items()} - if file == '': - # if not specified, try to read from the previously decompressed rom - file = decompressed_file + if os.path.isfile(decompressed_file): + # Try to read from previously decompressed rom if one exists. try: + self.read_rom(decompressed_file) + except (FileNotFoundError, RuntimeError): + # Decompress the provided file. + if not file: + raise FileNotFoundError('Must specify path to base ROM') self.read_rom(file) - except FileNotFoundError: - # could not find the decompressed rom either - raise FileNotFoundError('Must specify path to base ROM') - else: - self.read_rom(file) - - # decompress rom, or check if it's already decompressed - self.decompress_rom_file(file, decompressed_file) # Add file to maximum size self.buffer.extend(bytearray([0x00] * (0x4000000 - len(self.buffer)))) self.original = self.copy() - # Easy access to DMA entries. - self.dma: 'DMAIterator' = DMAIterator(self, DMADATA_START, DMADATA_INDEX) - # Add version number to header. self.write_version_bytes() @@ -66,59 +59,71 @@ def copy(self) -> 'Rom': new_rom.changed_address = copy.copy(self.changed_address) new_rom.changed_dma = copy.copy(self.changed_dma) new_rom.force_patch = copy.copy(self.force_patch) - new_rom.dma = DMAIterator(new_rom, DMADATA_START, DMADATA_INDEX) return new_rom - def decompress_rom_file(self, input_file: str, output_file: str, verify_crc: bool = True) -> None: + def read_rom(self, input_file: str, output_file: Optional[str] = None, verify_crc: bool = True) -> None: + try: + with open(input_file, 'rb') as stream: + self.buffer = bytearray(stream.read()) + except FileNotFoundError as ex: + raise FileNotFoundError(f'Invalid path to Base ROM: "{input_file}"') + + # Validate ROM file + if not verify_crc: + return + valid_crc = [ [0xEC, 0x70, 0x11, 0xB7, 0x76, 0x16, 0xD7, 0x2B], # Compressed [0x70, 0xEC, 0xB7, 0x11, 0x16, 0x76, 0x2B, 0xD7], # Byteswap compressed [0x93, 0x52, 0x2E, 0x7B, 0xE5, 0x06, 0xD4, 0x27], # Decompressed ] - # Validate ROM file file_name = os.path.splitext(input_file) rom_crc = list(self.buffer[0x10:0x18]) - if verify_crc and rom_crc not in valid_crc: + if rom_crc not in valid_crc: # Bad CRC validation raise RuntimeError('ROM file %s is not a valid OoT 1.0 US ROM.' % input_file) elif len(self.buffer) < 0x2000000 or len(self.buffer) > 0x4000000 or file_name[1].lower() not in ['.z64', '.n64']: - # ROM is too big, or too small, or not a bad type + # ROM is too big, or too small, or a bad type raise RuntimeError('ROM file %s is not a valid OoT 1.0 US ROM.' % input_file) elif len(self.buffer) == 0x2000000: # If Input ROM is compressed, then Decompress it - subcall = [] - - sub_dir = "./" if is_bundled() else "bin/Decompress/" - - if platform.system() == 'Windows': - if platform.machine() == 'AMD64': - subcall = [sub_dir + "Decompress.exe", input_file, output_file] - elif platform.machine() == 'ARM64': - subcall = [sub_dir + "Decompress_ARM64.exe", input_file, output_file] - else: - subcall = [sub_dir + "Decompress32.exe", input_file, output_file] - elif platform.system() == 'Linux': - if platform.machine() in ['arm64', 'aarch64', 'aarch64_be', 'armv8b', 'armv8l']: - subcall = [sub_dir + "Decompress_ARM64", input_file, output_file] - elif platform.machine() in ['arm', 'armv7l', 'armhf']: - subcall = [sub_dir + "Decompress_ARM32", input_file, output_file] - else: - subcall = [sub_dir + "Decompress", input_file, output_file] - elif platform.system() == 'Darwin': - if platform.machine() == 'arm64': - subcall = [sub_dir + "Decompress_ARM64.out", input_file, output_file] - else: - subcall = [sub_dir + "Decompress.out", input_file, output_file] + if output_file: + self.decompress_rom(input_file, output_file, verify_crc) else: - raise RuntimeError('Unsupported operating system for decompression. Please supply an already decompressed ROM.') - - subprocess.call(subcall, **subprocess_args()) - self.read_rom(output_file) + raise RuntimeError('ROM was unable to be decompressed. Please supply an already decompressed ROM.') else: # ROM file is a valid and already uncompressed pass + def decompress_rom(self, input_file: str, output_file: str, verify_crc: bool = True) -> None: + sub_dir = "./" if is_bundled() else "bin/Decompress/" + + if platform.system() == 'Windows': + if platform.machine() == 'AMD64': + subcall = [sub_dir + "Decompress.exe", input_file, output_file] + elif platform.machine() == 'ARM64': + subcall = [sub_dir + "Decompress_ARM64.exe", input_file, output_file] + else: + subcall = [sub_dir + "Decompress32.exe", input_file, output_file] + elif platform.system() == 'Linux': + if platform.machine() in ['arm64', 'aarch64', 'aarch64_be', 'armv8b', 'armv8l']: + subcall = [sub_dir + "Decompress_ARM64", input_file, output_file] + elif platform.machine() in ['arm', 'armv7l', 'armhf']: + subcall = [sub_dir + "Decompress_ARM32", input_file, output_file] + else: + subcall = [sub_dir + "Decompress", input_file, output_file] + elif platform.system() == 'Darwin': + if platform.machine() == 'arm64': + subcall = [sub_dir + "Decompress_ARM64.out", input_file, output_file] + else: + subcall = [sub_dir + "Decompress.out", input_file, output_file] + else: + raise RuntimeError('Unsupported operating system for decompression. Please supply an already decompressed ROM.') + + subprocess.call(subcall, **subprocess_args()) + self.read_rom(output_file, verify_crc=verify_crc) + def write_byte(self, address: int, value: int) -> None: super().write_byte(address, value) self.changed_address[self.last_address-1] = value @@ -144,11 +149,11 @@ def restore(self) -> None: self.changed_address = {} self.changed_dma = {} self.force_patch = [] - self.last_address = None + self.last_address = 0 self.write_version_bytes() def sym(self, symbol_name: str) -> int: - return self.symbols.get(symbol_name) + return self.symbols[symbol_name] def write_to_file(self, file: str) -> None: self.verify_dmadata() @@ -162,7 +167,7 @@ def update_header(self) -> None: def write_version_bytes(self) -> None: version_bytes = get_version_bytes(base_version, branch_identifier, supplementary_version) - self.write_bytes(0x19, version_bytes) + self.write_bytes(0x19, version_bytes[:5]) self.write_bytes(0x35, version_bytes[:3]) self.force_patch.extend([0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x35, 0x36, 0x37]) @@ -174,14 +179,6 @@ def read_version_bytes(self) -> bytearray: return secondary_version_bytes return version_bytes - def read_rom(self, file: str) -> None: - # "Reads rom into bytearray" - try: - with open(file, 'rb') as stream: - self.buffer = bytearray(stream.read()) - except FileNotFoundError as ex: - raise FileNotFoundError('Invalid path to Base ROM: "' + file + '"') - # dmadata/file management helper functions def verify_dmadata(self) -> None: @@ -297,8 +294,18 @@ def __init__(self, rom: Rom, dma_start: int, dma_index: int) -> None: self.rom: Rom = rom self.dma_start: int = dma_start self.dma_index: int = dma_index - self.dma_end: int = self.rom.read_int32(self.dma_start + (self.dma_index * 0x10) + 0x04) - self.dma_entries: int = (self.dma_end - self.dma_start) >> 4 + self.dma_end: int = 0 + self._dma_entries: int = 0 + + @property + def dma_entries(self) -> int: + if not self._dma_entries: + self._calculate_dma_entries() + return self._dma_entries + + def _calculate_dma_entries(self) -> None: + self.dma_end = self.rom.read_int32(self.dma_start + (self.dma_index * 0x10) + 0x04) + self._dma_entries = (self.dma_end - self.dma_start) >> 4 def __getitem__(self, item: int) -> DMAEntry: if not isinstance(item, int): @@ -310,18 +317,18 @@ def __getitem__(self, item: int) -> DMAEntry: return DMAEntry(self.rom, item) - def __iter__(self) -> Iterable[DMAEntry]: + def __iter__(self) -> Iterator[DMAEntry]: for item in range(0, self.dma_entries): yield self[item] # Gets a dmadata entry by the file start position. - def get_dmadata_record_by_key(self, key: Optional[int]) -> Optional[DMAEntry]: + def get_dmadata_record_by_key(self, key: Optional[int]) -> DMAEntry: for dma_entry in self: if key is None and dma_entry.end == 0 and dma_entry.start == 0: return dma_entry elif dma_entry.start == key: return dma_entry - return None + raise Exception(f"`get_dmadata_record_by_key`: DMA Start '{key}' not found in the DMA Table.") # gets the last used byte of rom defined in the DMA table def free_space(self) -> int: diff --git a/RuleParser.py b/RuleParser.py index 6e555985e..b331c4a8b 100644 --- a/RuleParser.py +++ b/RuleParser.py @@ -45,7 +45,7 @@ def load_aliases() -> None: args = [re.compile(fr'\b{a.strip()}\b') for a in args.split(',')] else: rule = s - args = () + args = [] rule_aliases[rule] = (args, repl) nonaliases.update(escaped_items.keys()) nonaliases.difference_update(rule_aliases.keys()) @@ -58,7 +58,7 @@ def isliteral(expr: ast.expr) -> bool: class Rule_AST_Transformer(ast.NodeTransformer): def __init__(self, world: "World") -> None: self.world: "World" = world - self.current_spot: Optional[Location, Entrance] = None + self.current_spot: Optional[Union[Location, Entrance]] = None self.events: Set[str] = set() # map Region -> rule ast string -> item name self.replaced_rules: Dict[str, Dict[str, ast.Call]] = defaultdict(dict) @@ -77,7 +77,7 @@ def visit_Name(self, node: ast.Name) -> Any: args, repl = rule_aliases[node.id] if args: raise Exception(f'Parse Error: expected {len(args):d} args for {node.id}, not 0', - self.current_spot.name, ast.dump(node, False)) + self.current_spot, ast.dump(node, False)) return self.visit(ast.parse(repl, mode='eval').body) elif node.id in escaped_items: return ast.Call( @@ -108,7 +108,7 @@ def visit_Name(self, node: ast.Name) -> Any: args=[node], keywords=[]) else: - raise Exception('Parse Error: invalid node name %s' % node.id, self.current_spot.name, ast.dump(node, False)) + raise Exception('Parse Error: invalid node name %s' % node.id, self.current_spot, ast.dump(node, False)) def visit_Str(self, node: ast.Str) -> Any: esc = escape_name(node.s) @@ -132,7 +132,7 @@ def visit_Constant(self, node: ast.Constant) -> Any: def visit_Tuple(self, node: ast.Tuple) -> Any: if len(node.elts) != 2: - raise Exception('Parse Error: Tuple must have 2 values', self.current_spot.name, ast.dump(node, False)) + raise Exception('Parse Error: Tuple must have 2 values', self.current_spot, ast.dump(node, False)) item, count = node.elts @@ -168,8 +168,8 @@ def visit_Call(self, node: ast.Call) -> Any: elif node.func.id in rule_aliases: args, repl = rule_aliases[node.func.id] if len(args) != len(node.args): - raise Exception('Parse Error: expected %d args for %s, not %d' % (len(args), node.func.id, len(node.args)), - self.current_spot.name, ast.dump(node, False)) + raise Exception(f'Parse Error: expected {len(args):d} args for {node.func.id}, not {len(node.args):d}', + self.current_spot, ast.dump(node, False)) # straightforward string manip for arg_re, arg_val in zip(args, node.args): if isinstance(arg_val, ast.Name): @@ -180,7 +180,7 @@ def visit_Call(self, node: ast.Call) -> Any: val = repr(arg_val.s) else: raise Exception('Parse Error: invalid argument %s' % ast.dump(arg_val, False), - self.current_spot.name, ast.dump(node, False)) + self.current_spot, ast.dump(node, False)) repl = arg_re.sub(val, repl) return self.visit(ast.parse(repl, mode='eval').body) diff --git a/RulesCommon.py b/RulesCommon.py index 871e64c87..f6a0f0dff 100644 --- a/RulesCommon.py +++ b/RulesCommon.py @@ -10,11 +10,10 @@ from typing import Protocol class AccessRule(Protocol): - def __call__(self, state: "State", **kwargs): + def __call__(self, state: "State", **kwargs) -> bool: ... else: - from Utils import TypeAlias - AccessRule: TypeAlias = Callable[["State"], bool] + AccessRule = Callable[["State"], bool] # Variable names and values used by rule execution, diff --git a/SaveContext.py b/SaveContext.py index 8121eb5f7..96f277e24 100644 --- a/SaveContext.py +++ b/SaveContext.py @@ -1,8 +1,13 @@ +import sys from enum import IntEnum from typing import TYPE_CHECKING, Dict, List, Iterable, Callable, Optional, Union, Any from ItemPool import IGNORE_LOCATION -from Utils import TypeAlias + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str if TYPE_CHECKING: from Rom import Rom @@ -46,7 +51,7 @@ class FlagType(IntEnum): class Address: - prev_address: Optional[int] = None + prev_address: int = 0 EXTENDED_CONTEXT_START = 0x1450 def __init__(self, address: Optional[int] = None, extended: bool = False, size: int = 4, mask: int = 0xFFFFFFFF, diff --git a/SceneFlags.py b/SceneFlags.py index 706f806e0..10d3f37eb 100644 --- a/SceneFlags.py +++ b/SceneFlags.py @@ -26,7 +26,7 @@ def get_collectible_flag_table(world: "World") -> "Tuple[Dict[int, Dict[int, int primary_tuple = default[0] for c in range(1, len(default)): alt_list.append((location, default[c], primary_tuple)) - default = location.default[0] # Use the first tuple as the primary tuple + default = primary_tuple # Use the first tuple as the primary tuple if isinstance(default, tuple): room, setup, flag = default room_setup = room + (setup << 6) @@ -65,6 +65,9 @@ def get_alt_list_bytes(alt_list: "List[Tuple[Location, Tuple[int, int, int], Tup for entry in alt_list: location, alt, primary = entry room, scene_setup, flag = alt + if location.scene is None: + continue + alt_override = (room << 8) + (scene_setup << 14) + flag room, scene_setup, flag = primary primary_override = (room << 8) + (scene_setup << 14) + flag diff --git a/Search.py b/Search.py index 8405f2d4b..015024715 100644 --- a/Search.py +++ b/Search.py @@ -1,10 +1,15 @@ import copy import itertools +import sys from typing import TYPE_CHECKING, Dict, List, Tuple, Iterable, Set, Callable, Union, Optional from Region import Region, TimeOfDay from State import State -from Utils import TypeAlias + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str if TYPE_CHECKING: from Entrance import Entrance @@ -54,9 +59,12 @@ def copy(self) -> 'Search': def collect_all(self, itempool: "Iterable[Item]") -> None: for item in itempool: - self.state_list[item.world.id].collect(item) + if item.solver_id is not None and item.world is not None: + self.state_list[item.world.id].collect(item) def collect(self, item: "Item") -> None: + if item.world is None: + raise Exception(f"Item '{item.name}' cannot be collected as it does not have a world.") self.state_list[item.world.id].collect(item) @classmethod @@ -89,6 +97,8 @@ def unvisit(self, location: "Location") -> None: # Drops the item from its respective state. # Has no effect on cache! def uncollect(self, item: "Item") -> None: + if item.world is None: + raise Exception(f"Item '{item.name}' cannot be uncollected as it does not have a world.") self.state_list[item.world.id].remove(item) # Resets the sphere cache to the first entry only. @@ -103,7 +113,7 @@ def reset(self) -> None: def _expand_regions(self, exit_queue: "List[Entrance]", regions: Dict[Region, int], age: Optional[str]) -> "List[Entrance]": failed = [] for exit in exit_queue: - if exit.connected_region and exit.connected_region not in regions: + if exit.world and exit.connected_region and exit.connected_region not in regions: # Evaluate the access rule directly, without tod if exit.access_rule(self.state_list[exit.world.id], spot=exit, age=age): # If it found a new tod, make sure we try other entrances again. @@ -153,8 +163,8 @@ def next_sphere(self) -> "Tuple[Dict[Region, int], Dict[Region, int], Set[Locati }) return self._cache['child_regions'], self._cache['adult_regions'], self._cache['visited_locations'] - # Yields every reachable location, by iteratively deepening explored sets of - # regions (one as child, one as adult) and invoking access rules. + # Yields every reachable location, by iteratively deepening explored sets of regions + # (one as child, one as adult) and invoking access rules. # item_locations is a list of Location objects from state_list that the caller # has prefiltered (eg. by whether they contain advancement items). # @@ -292,7 +302,8 @@ def iter_pseudo_starting_locations(self) -> "Iterable[Location]": def collect_pseudo_starting_items(self) -> None: for location in self.iter_pseudo_starting_locations(): - self.collect(location.item) + if location.item and location.item.solver_id is not None: + self.collect(location.item) # Use the cache in the search to determine region reachability. # Implicitly requires is_starting_age or Time_Travel. diff --git a/SettingTypes.py b/SettingTypes.py index 973758da5..8524aa49e 100644 --- a/SettingTypes.py +++ b/SettingTypes.py @@ -1,19 +1,22 @@ import math import operator -from typing import Any, Optional +from typing import Dict, Optional, Union, Any # holds the info for a single setting class SettingInfo: - def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Optional[str], shared: bool, + choices: Optional[Union[dict, list]] = None, default: Any = None, disabled_default: Any = None, + disable: Optional[dict] = None, gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, + cosmetic: bool = False) -> None: self.type: type = setting_type # type of the setting's value, used to properly convert types to setting strings self.shared: bool = shared # whether the setting is one that should be shared, used in converting settings to a string self.cosmetic: bool = cosmetic # whether the setting should be included in the cosmetic log self.gui_text: Optional[str] = gui_text self.gui_type: Optional[str] = gui_type self.gui_tooltip: Optional[str] = "" if gui_tooltip is None else gui_tooltip - self.gui_params: dict = {} if gui_params is None else gui_params # additional parameters that the randomizer uses for the gui - self.disable: dict = disable # dictionary of settings this setting disabled + self.gui_params: Dict[str, Any] = {} if gui_params is None else gui_params # additional parameters that the randomizer uses for the gui + self.disable: Optional[dict] = disable # dictionary of settings this setting disabled self.dependency = None # lambda that determines if this is disabled. Generated later # dictionary of options to their text names @@ -87,8 +90,11 @@ def create_dependency(self, disabling_setting: 'SettingInfo', option, negative: class SettingInfoNone(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], gui_tooltip=None, gui_params=None) -> None: - super().__init__(type(None), gui_text, gui_type, False, None, None, None, None, gui_tooltip, gui_params, False) + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], gui_tooltip: Optional[str] = None, + gui_params: Optional[dict] = None) -> None: + super().__init__(setting_type=type(None), gui_text=gui_text, gui_type=gui_type, shared=False, choices=None, + default=None, disabled_default=None, disable=None, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=False) def __get__(self, obj, obj_type=None) -> None: raise Exception(f"{self.name} is not a setting and cannot be retrieved.") @@ -98,13 +104,17 @@ def __set__(self, obj, value: str) -> None: class SettingInfoBool(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, default: Optional[bool] = None, + disabled_default: Optional[bool] = None, disable: Optional[dict] = None, gui_tooltip: Optional[str] = None, + gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: choices = { True: 'checked', False: 'unchecked', } - super().__init__(bool, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + super().__init__(setting_type=bool, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices, + default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) def __get__(self, obj, obj_type=None) -> bool: value = super().__get__(obj, obj_type) @@ -119,8 +129,13 @@ def __set__(self, obj, value: bool) -> None: class SettingInfoStr(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool = False, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(str, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool = False, + choices: Optional[Union[dict, list]] = None, default: Optional[str] = None, + disabled_default: Optional[str] = None, disable: Optional[dict] = None, + gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(setting_type=str, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices, + default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) def __get__(self, obj, obj_type=None) -> str: value = super().__get__(obj, obj_type) @@ -135,8 +150,13 @@ def __set__(self, obj, value: str) -> None: class SettingInfoInt(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(int, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, + choices: Optional[Union[dict, list]] = None, default: Optional[int] = None, + disabled_default: Optional[int] = None, disable: Optional[dict] = None, + gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(setting_type=int, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices, + default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) def __get__(self, obj, obj_type=None) -> int: value = super().__get__(obj, obj_type) @@ -151,8 +171,13 @@ def __set__(self, obj, value: int) -> None: class SettingInfoList(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(list, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, + choices: Optional[Union[dict, list]] = None, default: Optional[list] = None, + disabled_default: Optional[list] = None, disable: Optional[dict] = None, + gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(setting_type=list, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices, + default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) def __get__(self, obj, obj_type=None) -> list: value = super().__get__(obj, obj_type) @@ -167,8 +192,13 @@ def __set__(self, obj, value: list) -> None: class SettingInfoDict(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(dict, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, + choices: Optional[Union[dict, list]] = None, default: Optional[dict] = None, + disabled_default: Optional[dict] = None, disable: Optional[dict] = None, + gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(setting_type=dict, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices, + default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) def __get__(self, obj, obj_type=None) -> dict: value = super().__get__(obj, obj_type) @@ -183,52 +213,84 @@ def __set__(self, obj, value: dict) -> None: class Button(SettingInfoNone): - def __init__(self, gui_text: Optional[str], gui_tooltip=None, gui_params=None) -> None: - super().__init__(gui_text, "Button", gui_tooltip, gui_params) + def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, + gui_params: Optional[dict] = None) -> None: + super().__init__(gui_text=gui_text, gui_type="Button", gui_tooltip=gui_tooltip, gui_params=gui_params) class Textbox(SettingInfoNone): - def __init__(self, gui_text: Optional[str], gui_tooltip=None, gui_params=None) -> None: - super().__init__(gui_text, "Textbox", gui_tooltip, gui_params) + def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, + gui_params: Optional[dict] = None) -> None: + super().__init__(gui_text=gui_text, gui_type="Textbox", gui_tooltip=gui_tooltip, gui_params=gui_params) class Checkbutton(SettingInfoBool): - def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, disable=None, disabled_default=None, default=False, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Checkbutton', shared, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, + disabled_default: Optional[bool] = None, default: bool = False, shared: bool = False, + gui_params: Optional[dict] = None, cosmetic: bool = False): + super().__init__(gui_text=gui_text, gui_type='Checkbutton', shared=shared, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Combobox(SettingInfoStr): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Combobox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]], default: Optional[str], + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='Combobox', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Radiobutton(SettingInfoStr): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Radiobutton', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]], default: Optional[str], + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='Radiobutton', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Fileinput(SettingInfoStr): - def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Fileinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]] = None, default: Optional[str] = None, + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='Fileinput', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Directoryinput(SettingInfoStr): - def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Directoryinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]] = None, default: Optional[str] = None, + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='Directoryinput', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Textinput(SettingInfoStr): - def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Textinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]] = None, default: Optional[str] = None, + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='Textinput', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class ComboboxInt(SettingInfoInt): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Combobox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]], default: Optional[int], + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[int] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='Combobox', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Scale(SettingInfoInt): - def __init__(self, gui_text, default, minimum, maximum, step=1, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + def __init__(self, gui_text: Optional[str], default: Optional[int], minimum: int, maximum: int, step: int = 1, + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[int] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: choices = { i: str(i) for i in range(minimum, maximum+1, step) } @@ -239,11 +301,16 @@ def __init__(self, gui_text, default, minimum, maximum, step=1, gui_tooltip=None gui_params['max'] = maximum gui_params['step'] = step - super().__init__(gui_text, 'Scale', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + super().__init__(gui_text=gui_text, gui_type='Scale', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class Numberinput(SettingInfoInt): - def __init__(self, gui_text, default, minimum=None, maximum=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + def __init__(self, gui_text: Optional[str], default: Optional[int], minimum: Optional[int] = None, + maximum: Optional[int] = None, gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, + disabled_default: Optional[int] = None, shared: bool = False, gui_params: Optional[dict] = None, + cosmetic: bool = False) -> None: if gui_params is None: gui_params = {} if minimum is not None: @@ -251,14 +318,24 @@ def __init__(self, gui_text, default, minimum=None, maximum=None, gui_tooltip=No if maximum is not None: gui_params['max'] = maximum - super().__init__(gui_text, 'Numberinput', shared, None, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + super().__init__(gui_text=gui_text, gui_type='Numberinput', shared=shared, choices=None, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class MultipleSelect(SettingInfoList): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'MultipleSelect', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]], default: Optional[list], + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[list] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='MultipleSelect', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) class SearchBox(SettingInfoList): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'SearchBox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + def __init__(self, gui_text: Optional[str], choices: Optional[Union[dict, list]], default: Optional[list], + gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[list] = None, + shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None: + super().__init__(gui_text=gui_text, gui_type='SearchBox', shared=shared, choices=choices, default=default, + disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip, + gui_params=gui_params, cosmetic=cosmetic) diff --git a/Settings.py b/Settings.py index 6f13debe2..75d1e29d3 100644 --- a/Settings.py +++ b/Settings.py @@ -10,13 +10,13 @@ import string import sys import textwrap -from typing import TYPE_CHECKING, Dict, List, Tuple, Set, Any, Optional +from typing import Dict, List, Tuple, Set, Any, Optional +import StartingItems from version import __version__ from Utils import local_path, data_path from SettingsList import SettingInfos, validate_settings from Plandomizer import Distribution -import StartingItems LEGACY_STARTING_ITEM_SETTINGS: Dict[str, Dict[str, StartingItems.Entry]] = { 'starting_equipment': StartingItems.equipment, @@ -27,8 +27,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): - def _get_help_string(self, action): - return textwrap.dedent(action.help) + def _get_help_string(self, action) -> Optional[str]: + if action.help is not None: + return textwrap.dedent(action.help) # 32 characters diff --git a/SettingsList.py b/SettingsList.py index 63572fac1..0027c33f1 100644 --- a/SettingsList.py +++ b/SettingsList.py @@ -4932,7 +4932,7 @@ def is_mapped(setting_name: str) -> bool: # When a string isn't found in the source list, attempt to get the closest match from the list # ex. Given "Recovery Hart" returns "Did you mean 'Recovery Heart'?" -def build_close_match(name: str, value_type: str, source_list: "Optional[Union[List[str]], Dict[str, List[Entrance]]]" = None) -> str: +def build_close_match(name: str, value_type: str, source_list: "Optional[Union[List[str], Dict[str, List[Entrance]]]]" = None) -> str: source = [] if value_type == 'item': source = ItemInfo.items.keys() diff --git a/SettingsListTricks.py b/SettingsListTricks.py index 790193223..7a1d97ba3 100644 --- a/SettingsListTricks.py +++ b/SettingsListTricks.py @@ -1,8 +1,11 @@ +from typing import Dict, Tuple, Union + + # Below is the list of possible glitchless tricks. # The order they are listed in is also the order in which # they appear to the user in the GUI, so a sensible order was chosen -logic_tricks: dict = { +logic_tricks: Dict[str, Dict[str, Union[str, Tuple[str, ...]]]] = { # General tricks diff --git a/Sounds.py b/Sounds.py index 120bae69a..f4827e3cb 100644 --- a/Sounds.py +++ b/Sounds.py @@ -32,8 +32,7 @@ from Utils import data_path # Python 3.6 support. We can drop the conditional usage of namedtuple if we decide to no longer support Python 3.6. -dataclass_supported = sys.version_info >= (3, 7) -if dataclass_supported: +if sys.version_info >= (3, 7): from dataclasses import dataclass else: from collections import namedtuple @@ -57,7 +56,7 @@ class Tags(Enum): # I'm now thinking it has to do with a limit of concurrent sounds) -if dataclass_supported: +if sys.version_info >= (3, 7): @dataclass(frozen=True) class Sound: id: int @@ -172,7 +171,7 @@ class Sounds(Enum): ZELDA_ADULT_GASP = Sound(0x6879, 'adult-zelda-gasp', 'Zelda Gasp (Adult)', [Tags.NAVI, Tags.HPLOW]) -if dataclass_supported: +if sys.version_info >= (3, 7): @dataclass(frozen=True) class SoundHook: name: str diff --git a/Spoiler.py b/Spoiler.py index f27c2c7a7..79a5c0c4c 100644 --- a/Spoiler.py +++ b/Spoiler.py @@ -84,7 +84,7 @@ def parse_data(self) -> None: spoiler_locations = sorted( [location for location in world.get_locations() if not location.locked and not location.type.startswith('Hint')], key=lambda x: location_sort_order.get(x.name, 100000)) - self.locations[world.id] = OrderedDict([(str(location), location.item) for location in spoiler_locations]) + self.locations[world.id] = OrderedDict([(str(location), location.item) for location in spoiler_locations if location.item is not None]) entrance_sort_order = { "Spawn": 0, @@ -125,13 +125,14 @@ def find_misc_hint_items(self) -> None: search = Search([world.state for world in self.worlds]) all_locations = [location for world in self.worlds for location in world.get_filled_locations()] for location in search.iter_reachable_locations(all_locations[:]): - search.collect(location.item) # include locations that are reachable but not part of the spoiler log playthrough in misc. item hints - location.maybe_set_misc_item_hints() + location.maybe_set_misc_hints() all_locations.remove(location) + if location.item and location.item.solver_id is not None: + search.collect(location.item) for location in all_locations: # finally, collect unreachable locations for misc. item hints - location.maybe_set_misc_item_hints() + location.maybe_set_misc_hints() def create_playthrough(self) -> None: logger = logging.getLogger('') @@ -180,7 +181,7 @@ def create_playthrough(self) -> None: for location in collected: # Collect the item for the state world it is for search.state_list[location.item.world.id].collect(location.item) - location.maybe_set_misc_item_hints() + location.maybe_set_misc_hints() logger.info('Collected %d spheres', len(collection_spheres)) self.full_playthrough = dict((location.name, i + 1) for i, sphere in enumerate(collection_spheres) for location in sphere) self.max_sphere = len(collection_spheres) diff --git a/State.py b/State.py index c6c1cc1cc..249cb0c6b 100644 --- a/State.py +++ b/State.py @@ -139,25 +139,28 @@ def guarantee_hint(self) -> bool: # Be careful using this function. It will not collect any # items that may be locked behind the item, only the item itself. def collect(self, item: Item) -> None: + if item.solver_id is None: + raise Exception(f"Item '{item.name}' lacks a `solver_id` and can not be used in `State.collect()`.") if 'Small Key Ring' in item.name and self.world.settings.keyring_give_bk: dungeon_name = item.name[:-1].split(' (', 1)[1] if dungeon_name in ['Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple']: bk = f'Boss Key ({dungeon_name})' self.solv_items[ItemInfo.solver_ids[escape_name(bk)]] = 1 - if item.alias: + if item.alias and item.alias_id is not None: self.solv_items[item.alias_id] += item.alias[1] - if item.advancement: - self.solv_items[item.solver_id] += 1 + self.solv_items[item.solver_id] += 1 # Be careful using this function. It will not uncollect any # items that may be locked behind the item, only the item itself. def remove(self, item: Item) -> None: + if item.solver_id is None: + raise Exception(f"Item '{item.name}' lacks a `solver_id` and can not be used in `State.remove()`.") if 'Small Key Ring' in item.name and self.world.settings.keyring_give_bk: dungeon_name = item.name[:-1].split(' (', 1)[1] if dungeon_name in ['Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple']: bk = f'Boss Key ({dungeon_name})' self.solv_items[ItemInfo.solver_ids[escape_name(bk)]] = 0 - if item.alias and self.solv_items[item.alias_id] > 0: + if item.alias and item.alias_id is not None and self.solv_items[item.alias_id] > 0: self.solv_items[item.alias_id] -= item.alias[1] if self.solv_items[item.alias_id] < 0: self.solv_items[item.alias_id] = 0 @@ -177,7 +180,7 @@ def get_prog_items(self) -> Dict[str, int]: return { **{item.name: self.solv_items[item.solver_id] for item in ItemInfo.items.values() - if item.junk is None and self.solv_items[item.solver_id]}, + if item.solver_id is not None}, **{event: self.solv_items[ItemInfo.solver_ids[event]] for event in self.world.event_items if self.solv_items[ItemInfo.solver_ids[event]]} diff --git a/Unittest.py b/Unittest.py index e1ce540d9..cf1c68280 100644 --- a/Unittest.py +++ b/Unittest.py @@ -7,9 +7,10 @@ import os import random import re +import sys import unittest from collections import Counter, defaultdict -from typing import Dict, Tuple, Optional, Union, Any +from typing import Dict, Tuple, Optional, Union, Any, overload from EntranceShuffle import EntranceShuffleError from Fill import ShuffleError @@ -22,6 +23,13 @@ from Settings import Settings, get_preset_files from Spoiler import Spoiler +if sys.version_info >= (3, 8): + from typing import Literal + LiteralTrue = Literal[True] + LiteralFalse = Literal[False] +else: + LiteralTrue = LiteralFalse = bool + test_dir = os.path.join(os.path.dirname(__file__), 'tests') output_dir = os.path.join(test_dir, 'Output') os.makedirs(output_dir, exist_ok=True) @@ -52,7 +60,7 @@ ludicrous_set = set(ludicrous_items_base) | set(ludicrous_items_extended) | ludicrous_junk | set(trade_items) | set(bottles) | set(ludicrous_exclusions) | {'Bottle with Big Poe'} | shop_items -def make_settings_for_test(settings_dict: Dict[str, Any], seed: Optional[str] = None, outfilename: str = None, strict: bool = True) -> Settings: +def make_settings_for_test(settings_dict: Dict[str, Any], seed: Optional[str] = None, outfilename: str = '', strict: bool = True) -> Settings: # Some consistent settings for testability settings_dict.update({ 'create_patch_file': False, @@ -70,14 +78,13 @@ def make_settings_for_test(settings_dict: Dict[str, Any], seed: Optional[str] = def load_settings(settings_file: Union[Dict[str, Any], str], seed: Optional[str] = None, filename: Optional[str] = None) -> Settings: if isinstance(settings_file, dict): # Check if settings_file is a distribution file settings dict - try: - j = settings_file - j.update({ - 'enable_distribution_file': True, - 'distribution_file': os.path.join(test_dir, 'plando', filename + '.json') - }) - except TypeError: + if filename is None: raise RuntimeError("Running test with in memory file but did not supply a filename for output file.") + j = settings_file + j.update({ + 'enable_distribution_file': True, + 'distribution_file': os.path.join(test_dir, 'plando', filename + '.json') + }) else: sfile = os.path.join(test_dir, settings_file) filename = os.path.splitext(settings_file)[0] @@ -91,6 +98,14 @@ def load_spoiler(json_file: str) -> Any: return json.load(f) +@overload +def generate_with_plandomizer(filename: str, live_copy: LiteralFalse = False, max_attempts: int = 10) -> Tuple[Dict[str, Any], Dict[str, Any]]: + pass + +@overload +def generate_with_plandomizer(filename: str, live_copy: LiteralTrue, max_attempts: int = 10) -> Tuple[Dict[str, Any], Spoiler]: + pass + def generate_with_plandomizer(filename: str, live_copy: bool = False, max_attempts: int = 10) -> Tuple[Dict[str, Any], Union[Spoiler, Dict[str, Any]]]: distribution_file = load_spoiler(os.path.join(test_dir, 'plando', filename + '.json')) try: @@ -193,7 +208,7 @@ def test_excess_starting_items(self): def test_rom_patching(self): # This makes sure there are no crashes while patching. - if not os.path.exists('./ZOOTDEC.z64'): + if not os.path.isfile('./ZOOTDEC.z64'): self.skipTest("Base ROM file not available.") filename = "plando-ammo-max-out-of-bounds" logic_rules_settings = ['glitchless', 'glitched', 'none'] @@ -546,7 +561,7 @@ def test_skip_zelda(self): self.assertIn('Hyrule Castle', woth) def test_ganondorf(self): - if not os.path.exists('./ZOOTDEC.z64'): + if not os.path.isfile('./ZOOTDEC.z64'): self.skipTest("Base ROM file not available.") filenames = [ "light-arrows-1", @@ -614,7 +629,7 @@ def test_those_pots_over_there(self): _, spoiler = generate_with_plandomizer(filename, live_copy=True) world = spoiler.worlds[0] location = spoiler.worlds[0].misc_hint_item_locations["ganondorf"] - area = HintArea.at(location, use_alt_hint=True).text(world.settings.clearer_hints, world=None if location.world.id == world.id else location.world.id + 1) + area = HintArea.at(location, use_alt_hint=True).text(world.settings.clearer_hints, world=None if not location.world or location.world.id == world.id else location.world.id + 1) self.assertEqual(area, "#Ganondorf's Chamber#") # Build a test message with the same ID as the ganondorf hint (0x70CC) messages = [Message("Test", 0, 0x70CC, 0,0,0)] diff --git a/Utils.py b/Utils.py index 510631edd..5660d829b 100644 --- a/Utils.py +++ b/Utils.py @@ -12,14 +12,6 @@ from version import __version__, base_version, supplementary_version, branch_url -# For easy import of TypeAlias that won't break older versions of Python. -if sys.version_info >= (3, 10): - # noinspection PyUnresolvedReferences - from typing import TypeAlias -else: - TypeAlias = str - - def is_bundled() -> bool: return getattr(sys, 'frozen', False) @@ -87,16 +79,6 @@ def open_file(filename: str) -> None: subprocess.call([open_command, filename]) -def close_console() -> None: - if sys.platform == 'win32': - # windows - import win32gui, win32con - try: - win32gui.ShowWindow(win32gui.GetForegroundWindow(), win32con.SW_HIDE) - except Exception: - pass - - def get_version_bytes(a: str, b: int = 0x00, c: int = 0x00) -> List[int]: version_bytes = [0x00, 0x00, 0x00, b, c] @@ -234,8 +216,14 @@ def run_process(logger: logging.Logger, args: Sequence[str], stdin: Optional[Any # https://stackoverflow.com/a/23146126 -def find_last(source_list: Sequence[Any], sought_element: Any) -> Optional[int]: +def try_find_last(source_list: Sequence[Any], sought_element: Any) -> Optional[int]: for reverse_index, element in enumerate(reversed(source_list)): if element == sought_element: return len(source_list) - 1 - reverse_index return None + +def find_last(source_list: Sequence[Any], sought_element: Any) -> int: + last = try_find_last(source_list, sought_element) + if last is None: + raise Exception(f"Element {sought_element} not found in sequence {source_list}.") + return last diff --git a/World.py b/World.py index d93052da1..2e01f8321 100644 --- a/World.py +++ b/World.py @@ -35,7 +35,7 @@ def __init__(self, world_id: int, settings: Settings, resolve_randomized_setting self._region_cache: Dict[str, Region] = {} self._location_cache: Dict[str, Location] = {} self.shop_prices: Dict[str, int] = {} - self.scrub_prices: Dict[str, int] = {} + self.scrub_prices: Dict[int, int] = {} self.maximum_wallets: int = 0 self.hinted_dungeon_reward_locations: Dict[str, Location] = {} self.misc_hint_item_locations: Dict[str, Location] = {} @@ -63,7 +63,7 @@ def __init__(self, world_id: int, settings: Settings, resolve_randomized_setting self.shuffle_special_dungeon_entrances: bool = settings.shuffle_dungeon_entrances == 'all' self.shuffle_dungeon_entrances: bool = settings.shuffle_dungeon_entrances in ['simple', 'all'] - self.entrance_shuffle: bool = ( + self.entrance_shuffle: bool = bool( self.shuffle_interior_entrances or settings.shuffle_grotto_entrances or self.shuffle_dungeon_entrances or settings.shuffle_overworld_entrances or settings.shuffle_gerudo_valley_river_exit or settings.owl_drops or settings.warp_songs or settings.spawn_positions or (settings.shuffle_bosses != 'off') @@ -71,7 +71,7 @@ def __init__(self, world_id: int, settings: Settings, resolve_randomized_setting self.mixed_pools_bosses = False # this setting is still in active development at https://github.com/Roman971/OoT-Randomizer - self.ensure_tod_access: bool = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.spawn_positions + self.ensure_tod_access: bool = bool(self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.spawn_positions) self.disable_trade_revert: bool = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.adult_trade_shuffle self.skip_child_zelda: bool = 'Zeldas Letter' not in settings.shuffle_child_trade and \ 'Zeldas Letter' in self.distribution.starting_items @@ -128,7 +128,7 @@ def __init__(self): def __missing__(self, dungeon_name: str) -> EmptyDungeonInfo: return self.EmptyDungeonInfo(None) - self.empty_dungeons: EmptyDungeons[str, EmptyDungeons.EmptyDungeonInfo] = EmptyDungeons() + self.empty_dungeons: Dict[str, EmptyDungeons.EmptyDungeonInfo] = EmptyDungeons() # dungeon forms will be decided later self.dungeon_mq: Dict[str, bool] = { diff --git a/ntype.py b/ntype.py index 3eeaee24c..6db5ac030 100644 --- a/ntype.py +++ b/ntype.py @@ -16,7 +16,7 @@ def read(cls, buffer: bytearray, address: int = 0) -> int: @staticmethod def bytes(value: int) -> bytearray: - value: int = value & 0xFFFF + value = value & 0xFFFF return bytearray([(value >> 8) & 0xFF, value & 0xFF]) @staticmethod @@ -37,7 +37,7 @@ def read(cls, buffer: bytearray, address: int = 0) -> int: @staticmethod def bytes(value: int) -> bytearray: - value: int = value & 0xFFFFFFFF + value = value & 0xFFFFFFFF return bytearray([(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]) @staticmethod @@ -58,7 +58,7 @@ def read(cls, buffer: bytearray, address: int = 0) -> int: @staticmethod def bytes(value: int) -> bytearray: - value: int = value & 0xFFFFFFFF + value = value & 0xFFFFFFFF return bytearray([(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]) @staticmethod @@ -82,7 +82,7 @@ def read(buffer: bytearray, address: int = 0) -> int: @staticmethod def bytes(value: int) -> bytearray: - value: int = value & 0xFFFFFF + value = value & 0xFFFFFF return bytearray([(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]) @staticmethod