From 5528aa1fb59e00f4ab86d3212c73144214aa29a4 Mon Sep 17 00:00:00 2001 From: Fenhl Date: Thu, 21 Mar 2024 11:19:19 +0000 Subject: [PATCH] Start fixing errors reported by mypy --- .github/workflows/release.yml | 2 +- CI.py | 37 ++++--- Colors.py | 36 ++++--- Cosmetics.py | 80 ++++++++------ EntranceShuffle.py | 112 +++++++++++++------- Fill.py | 48 ++++++--- Goals.py | 29 +++-- Gui.py | 10 +- HintList.py | 36 +++---- Hints.py | 129 ++++++++++++---------- IconManip.py | 2 +- Item.py | 4 +- ItemList.py | 194 +++++++++++++++++----------------- ItemPool.py | 4 +- JSONDump.py | 4 +- Location.py | 2 +- LocationList.py | 5 +- MQ.py | 11 +- Messages.py | 2 +- Models.py | 4 +- Music.py | 2 +- N64Patch.py | 2 +- OcarinaSongs.py | 6 +- Patches.py | 26 ++--- Plandomizer.py | 4 +- Region.py | 2 + Rom.py | 2 +- RuleParser.py | 4 +- Rules.py | 1 + SaveContext.py | 6 +- SceneFlags.py | 2 +- Search.py | 2 +- SettingTypes.py | 10 +- Settings.py | 3 +- SettingsList.py | 2 +- SettingsListTricks.py | 8 +- SettingsToJson.py | 2 +- Spoiler.py | 2 +- State.py | 4 +- TextBox.py | 2 +- Unittest.py | 2 +- Utils.py | 26 ++--- World.py | 15 +-- texture_util.py | 6 +- 44 files changed, 504 insertions(+), 388 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ab8a9b9c..544797f03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Python +name: Release on: push: diff --git a/CI.py b/CI.py index c9a39ffad..f98f0a633 100644 --- a/CI.py +++ b/CI.py @@ -10,7 +10,7 @@ import sys import unittest from io import StringIO -from typing import NoReturn +from typing import Any, NoReturn from Messages import ITEM_MESSAGES, KEYSANITY_MESSAGES, MISC_MESSAGES from SettingsList import SettingInfos, logic_tricks, validate_settings @@ -18,17 +18,26 @@ from Utils import data_path +ERROR_COUNT: int = 0 +ANY_FIXABLE_ERRORS: bool = False +ANY_FIXABLE_ERRORS_FOR_RELEASE_CHECKS: bool = False +ANY_UNFIXABLE_ERRORS: bool = False + + def error(msg: str, can_fix: bool | str) -> None: - if not hasattr(error, "count"): - error.count = 0 + global ERROR_COUNT + global ANY_FIXABLE_ERRORS + global ANY_FIXABLE_ERRORS_FOR_RELEASE_CHECKS + global ANY_UNFIXABLE_ERRORS + print(msg, file=sys.stderr) - error.count += 1 + ERROR_COUNT += 1 if can_fix: - error.can_fix = True + ANY_FIXABLE_ERRORS = True if can_fix == 'release': - error.can_fix_release = True + ANY_FIXABLE_ERRORS_FOR_RELEASE_CHECKS = True else: - error.cannot_fix = True + ANY_UNFIXABLE_ERRORS = True def run_unit_tests() -> None: @@ -132,7 +141,7 @@ def check_release_presets(fix_errors: bool = False) -> None: # This is not a perfect check because it doesn't account for everything that gets manually done in Patches.py # For that, we perform additional checking at patch time def check_message_duplicates() -> None: - def check_for_duplicates(new_item_messages: list[tuple[int, str]]) -> None: + def check_for_duplicates(new_item_messages: list[tuple[int, Any]]) -> None: for i in range(0, len(new_item_messages)): for j in range(i, len(new_item_messages)): if i != j: @@ -231,22 +240,22 @@ def run_ci_checks() -> NoReturn: def exit_ci(fix_errors: bool = False) -> NoReturn: - if hasattr(error, "count") and error.count: - print(f'CI failed with {error.count} errors.', file=sys.stderr) + if ERROR_COUNT > 0: + print(f'CI failed with {ERROR_COUNT} errors.', file=sys.stderr) if fix_errors: - if getattr(error, 'cannot_fix', False): + if ANY_UNFIXABLE_ERRORS: print('Some errors could not be fixed automatically.', file=sys.stderr) sys.exit(1) else: print('All errors fixed.', file=sys.stderr) sys.exit(0) else: - if getattr(error, 'can_fix', False): - if getattr(error, 'can_fix_release', False): + if ANY_FIXABLE_ERRORS: + if ANY_FIXABLE_ERRORS_FOR_RELEASE_CHECKS: release_arg = ' --release' else: release_arg = '' - if getattr(error, 'cannot_fix', False): + if ANY_UNFIXABLE_ERRORS: which_errors = 'some of these errors' else: which_errors = 'these errors' diff --git a/Colors.py b/Colors.py index 831db6104..1f0ac2cb2 100644 --- a/Colors.py +++ b/Colors.py @@ -1,8 +1,14 @@ from __future__ import annotations +import sys import random import re from collections import namedtuple +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + TypeAlias = str + Color = namedtuple('Color', ' R G B') tunic_colors: dict[str, Color] = { @@ -152,7 +158,8 @@ # A Button Text Cursor Shop Cursor Save/Death Cursor # Pause Menu A Cursor Pause Menu A Icon A Note -a_button_colors: dict[str, tuple[Color, Color, Color, Color, Color, Color, Color]] = { +AButtonColors: TypeAlias = "dict[str, tuple[Color, Color, Color, Color, Color, Color, Color]]" +a_button_colors: AButtonColors = { "N64 Blue": (Color(0x5A, 0x5A, 0xFF), Color(0x00, 0x50, 0xC8), Color(0x00, 0x50, 0xFF), Color(0x64, 0x64, 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(0x64, 0x96, 0x64), @@ -208,7 +215,8 @@ } # C Button Pause Menu C Cursor Pause Menu C Icon C Note -c_button_colors: dict[str, tuple[Color, Color, Color, Color]] = { +CButtonColors: TypeAlias = "dict[str, tuple[Color, Color, Color, Color]]" +c_button_colors: CButtonColors = { "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)), @@ -366,14 +374,14 @@ def get_start_button_color_options() -> list[str]: return meta_color_choices + get_start_button_colors() -def contrast_ratio(color1: list[int], color2: list[int]) -> float: +def contrast_ratio(color1: Color, color2: Color) -> float: # Based on accessibility standards (WCAG 2.0) lum1 = relative_luminance(color1) lum2 = relative_luminance(color2) return (max(lum1, lum2) + 0.05) / (min(lum1, lum2) + 0.05) -def relative_luminance(color: list[int]) -> float: +def relative_luminance(color: Color) -> float: color_ratios = list(map(lum_color_ratio, color)) return color_ratios[0] * 0.299 + color_ratios[1] * 0.587 + color_ratios[2] * 0.114 @@ -386,23 +394,21 @@ def lum_color_ratio(val: float) -> float: return pow((val + 0.055) / 1.055, 2.4) -def generate_random_color() -> list[int]: - return [random.getrandbits(8), random.getrandbits(8), random.getrandbits(8)] - +def generate_random_color() -> Color: + return Color(random.getrandbits(8), random.getrandbits(8), random.getrandbits(8)) -def hex_to_color(option: str) -> list[int]: - if not hasattr(hex_to_color, "regex"): - hex_to_color.regex = re.compile(r'^(?:[0-9a-fA-F]{3}){1,2}$') +HEX_TO_COLOR_REGEX: re.Pattern[str] = re.compile(r'^(?:[0-9a-fA-F]{3}){1,2}$') +def hex_to_color(option: str) -> Color: # build color from hex code option = option[1:] if option[0] == "#" else option - if not hex_to_color.regex.search(option): + if not HEX_TO_COLOR_REGEX.search(option): raise Exception(f"Invalid color value provided: {option}") if len(option) > 3: - return list(int(option[i:i + 2], 16) for i in (0, 2, 4)) + return Color(*(int(option[i:i + 2], 16) for i in (0, 2, 4))) else: - return list(int(f'{option[i]}{option[i]}', 16) for i in (0, 1, 2)) + return Color(*(int(f'{option[i]}{option[i]}', 16) for i in (0, 1, 2))) -def color_to_hex(color: list[int]) -> str: - return '#' + ''.join(['{:02X}'.format(c) for c in color]) +def color_to_hex(color: Color) -> str: + return '#' + ''.join('{:02X}'.format(c) for c in color) diff --git a/Cosmetics.py b/Cosmetics.py index 712639e49..d5cfa0a54 100644 --- a/Cosmetics.py +++ b/Cosmetics.py @@ -5,9 +5,10 @@ import random from collections.abc import Iterable, Callable from itertools import chain -from typing import TYPE_CHECKING, Optional, Any +from typing import TYPE_CHECKING, Optional, Any, TypedDict import Colors +from Colors import Color import IconManip import Music import Sounds @@ -57,7 +58,7 @@ def patch_music(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: dict[s rom.write_byte(0xBE447F, 0x00) -def patch_model_colors(rom: Rom, color: Optional[list[int]], model_addresses: tuple[list[int], list[int], list[int]]) -> None: +def patch_model_colors(rom: Rom, color: Optional[Color], model_addresses: tuple[list[int], list[int], list[int]]) -> None: main_addresses, dark_addresses, light_addresses = model_addresses if color is None: @@ -78,7 +79,7 @@ def patch_model_colors(rom: Rom, color: Optional[list[int]], model_addresses: tu rom.write_bytes(address, lightened_color) -def patch_tunic_icon(rom: Rom, tunic: str, color: Optional[list[int]], rainbow: bool = False) -> None: +def patch_tunic_icon(rom: Rom, tunic: str, color: Optional[Color], rainbow: bool = False) -> None: # patch tunic icon colors icon_locations = { 'Kokiri Tunic': 0x007FE000, @@ -139,9 +140,9 @@ def patch_tunic_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: color = Colors.generate_random_color() # grab the color from the list elif tunic_option in Colors.tunic_colors: - color = list(Colors.tunic_colors[tunic_option]) + color = Colors.tunic_colors[tunic_option] elif tunic_option == 'Rainbow': - color = list(Colors.Color(0x00, 0x00, 0x00)) + color = Color(0x00, 0x00, 0x00) # build color from hex code else: color = Colors.hex_to_color(tunic_option) @@ -203,7 +204,7 @@ def patch_navi_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: colors = [] option_dict = {} for address_index, address in enumerate(navi_addresses): - address_colors = {} + address_colors: dict[str, Color] = {} colors.append(address_colors) for index, (navi_part, option, rainbow_symbol) in enumerate([ ('inner', navi_option_inner, rainbow_inner_symbol), @@ -218,7 +219,7 @@ def patch_navi_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: # set rainbow option if rainbow_symbol is not None and option == 'Rainbow': rom.write_byte(rainbow_symbol, 0x01) - color = [0x00, 0x00, 0x00] + color = Color(0x00, 0x00, 0x00) elif rainbow_symbol is not None: rom.write_byte(rainbow_symbol, 0x00) elif option == 'Rainbow': @@ -231,7 +232,7 @@ def patch_navi_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: # grab the color from the list if color is None and option in Colors.NaviColors: - color = list(Colors.NaviColors[option][index]) + color = Colors.NaviColors[option][index] # build color from hex code if color is None: @@ -246,8 +247,7 @@ def patch_navi_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: option_dict[navi_part] = option # write color - color = address_colors['inner'] + [0xFF] + address_colors['outer'] + [0xFF] - rom.write_bytes(address, color) + rom.write_bytes(address, [*address_colors['inner'], 0xFF, *address_colors['outer'], 0xFF]) # Get the colors into the log. log.misc_colors[navi_action] = CollapseDict({ @@ -300,7 +300,7 @@ def patch_sword_trails(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: colors = [] option_dict = {} for address_index, (address, inner_transparency, inner_white_transparency, outer_transparency, outer_white_transparency) in enumerate(trail_addresses): - address_colors = {} + address_colors: dict[str, Color] = {} colors.append(address_colors) transparency_dict = {} for index, (trail_part, option, rainbow_symbol, white_transparency, transparency) in enumerate([ @@ -316,7 +316,7 @@ def patch_sword_trails(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: # set rainbow option if rainbow_symbol is not None and option == 'Rainbow': rom.write_byte(rainbow_symbol, 0x01) - color = [0x00, 0x00, 0x00] + color = Color(0x00, 0x00, 0x00) elif rainbow_symbol is not None: rom.write_byte(rainbow_symbol, 0x00) elif option == 'Rainbow': @@ -329,7 +329,7 @@ def patch_sword_trails(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: # grab the color from the list if color is None and option in Colors.sword_trail_colors: - color = list(Colors.sword_trail_colors[option]) + color = Colors.sword_trail_colors[option] # build color from hex code if color is None: @@ -350,8 +350,7 @@ def patch_sword_trails(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: option_dict[trail_part] = option # write color - color = address_colors['outer'] + [transparency_dict['outer']] + address_colors['inner'] + [transparency_dict['inner']] - rom.write_bytes(address, color) + rom.write_bytes(address, [*address_colors['outer'], transparency_dict['outer'], *address_colors['inner'], transparency_dict['inner']]) # Get the colors into the log. log.misc_colors[trail_name] = CollapseDict({ @@ -395,7 +394,7 @@ def patch_boomerang_trails(rom: Rom, settings: Settings, log: CosmeticsLog, symb patch_trails(rom, settings, log, boomerang_trails) -def patch_trails(rom: Rom, settings: Settings, log: CosmeticsLog, trails) -> None: +def patch_trails(rom: Rom, settings: Settings, log: CosmeticsLog, trails: list[tuple[str, str, list[str], dict[str, Color], tuple[int, int, int, int]]]) -> None: for trail_name, trail_setting, trail_color_list, trail_color_dict, trail_symbols in trails: color_inner_symbol, color_outer_symbol, rainbow_inner_symbol, rainbow_outer_symbol = trail_symbols option_inner = getattr(settings, f'{trail_setting}_inner') @@ -427,7 +426,7 @@ def patch_trails(rom: Rom, settings: Settings, log: CosmeticsLog, trails) -> Non # set rainbow option if option == 'Rainbow': rom.write_byte(rainbow_symbol, 0x01) - color = [0x00, 0x00, 0x00] + color = Color(0x00, 0x00, 0x00) else: rom.write_byte(rainbow_symbol, 0x00) @@ -435,8 +434,8 @@ def patch_trails(rom: Rom, settings: Settings, log: CosmeticsLog, trails) -> Non if color is None and option == 'Completely Random': # Specific handling for inner bombchu trails for contrast purposes. if trail_name == 'Bombchu Trail' and trail_part == 'inner': - fixed_dark_color = [0, 0, 0] - color = [0, 0, 0] + fixed_dark_color = Color(0, 0, 0) + color = Color(0, 0, 0) # Avoid colors which have a low contrast so the bombchu ticking is still visible while Colors.contrast_ratio(color, fixed_dark_color) <= 4: color = Colors.generate_random_color() @@ -445,7 +444,7 @@ def patch_trails(rom: Rom, settings: Settings, log: CosmeticsLog, trails) -> Non # grab the color from the list if color is None and option in trail_color_dict: - color = list(trail_color_dict[option]) + color = trail_color_dict[option] # build color from hex code if color is None: @@ -476,7 +475,7 @@ def patch_trails(rom: Rom, settings: Settings, log: CosmeticsLog, trails) -> Non def patch_gauntlet_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: dict[str, int]) -> None: # patch gauntlet colors - gauntlets = [ + gauntlets: list[tuple[str, str, int, tuple[list[int], list[int], list[int]]]] = [ ('Silver Gauntlets', 'silver_gauntlets_color', 0x00B6DA44, ([0x173B4CC], [0x173B4D4, 0x173B50C, 0x173B514], [])), # GI Model DList colors ('Gold Gauntlets', 'golden_gauntlets_color', 0x00B6DA47, @@ -499,7 +498,7 @@ def patch_gauntlet_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbo color = Colors.generate_random_color() # grab the color from the list elif gauntlet_option in Colors.gauntlet_colors: - color = list(Colors.gauntlet_colors[gauntlet_option]) + color = Colors.gauntlet_colors[gauntlet_option] # build color from hex code else: color = Colors.hex_to_color(gauntlet_option) @@ -517,7 +516,7 @@ def patch_gauntlet_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbo def patch_shield_frame_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: dict[str, int]) -> None: # patch shield frame colors - shield_frames = [ + shield_frames: list[tuple[str, str, list[int], tuple[list[int], list[int], list[int]]]] = [ ('Mirror Shield Frame', 'mirror_shield_frame_color', [0xFA7274, 0xFA776C, 0xFAA27C, 0xFAC564, 0xFAC984, 0xFAEDD4], ([0x1616FCC], [0x1616FD4], [])), @@ -536,10 +535,10 @@ def patch_shield_frame_colors(rom: Rom, settings: Settings, log: CosmeticsLog, s shield_frame_option = random.choice(shield_frame_color_list) # handle completely random if shield_frame_option == 'Completely Random': - color = [random.getrandbits(8), random.getrandbits(8), random.getrandbits(8)] + color = Colors.generate_random_color() # grab the color from the list elif shield_frame_option in Colors.shield_frame_colors: - color = list(Colors.shield_frame_colors[shield_frame_option]) + color = Colors.shield_frame_colors[shield_frame_option] # build color from hex code else: color = Colors.hex_to_color(shield_frame_option) @@ -583,7 +582,7 @@ def patch_heart_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: color = Colors.generate_random_color() # grab the color from the list elif heart_option in Colors.heart_colors: - color = list(Colors.heart_colors[heart_option]) + color = Colors.heart_colors[heart_option] # build color from hex code else: color = Colors.hex_to_color(heart_option) @@ -630,7 +629,7 @@ def patch_magic_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: if magic_option == 'Completely Random': color = Colors.generate_random_color() elif magic_option in Colors.magic_colors: - color = list(Colors.magic_colors[magic_option]) + color = Colors.magic_colors[magic_option] else: color = Colors.hex_to_color(magic_option) magic_option = 'Custom' @@ -650,7 +649,7 @@ def patch_magic_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: def patch_button_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols: dict[str, int]) -> None: - buttons = [ + buttons: list[tuple[str, str, Colors.AButtonColors | Colors.CButtonColors | dict[str, Color], list[tuple[str, Optional[int], Optional[list[tuple[int, int, int]]]]]]] = [ ('A Button Color', 'a_button_color', Colors.a_button_colors, [('A Button Color', symbols['CFG_A_BUTTON_COLOR'], None), @@ -702,14 +701,18 @@ def patch_button_colors(rom: Rom, settings: Settings, log: CosmeticsLog, symbols button_option = random.choice(list(button_colors.keys())) # handle completely random if button_option == 'Completely Random': - fixed_font_color = [10, 10, 10] - color = [0, 0, 0] + fixed_font_color = Color(10, 10, 10) + color = Color(0, 0, 0) # Avoid colors which have a low contrast with the font inside buttons (eg. the A letter) while Colors.contrast_ratio(color, fixed_font_color) <= 3: color = Colors.generate_random_color() # grab the color from the list elif button_option in button_colors: - color_set = [button_colors[button_option]] if isinstance(button_colors[button_option][0], int) else list(button_colors[button_option]) + button_color = button_colors[button_option] + if isinstance(button_color, Color): + color_set = [button_color] + else: + color_set = list(button_color) color = color_set[0] # build color from hex code else: @@ -1237,6 +1240,12 @@ def patch_cosmetics(settings: Settings, rom: Rom) -> CosmeticsLog: return log +class BgmGroups(TypedDict): + favorites: list + exclude: list + groups: dict + + class CosmeticsLog: def __init__(self, settings: Settings) -> None: self.settings: Settings = settings @@ -1246,7 +1255,6 @@ def __init__(self, settings: Settings) -> None: self.misc_colors: dict[str, dict] = {} self.sfx: dict[str, str] = {} self.bgm: dict[str, str] = {} - self.bgm_groups: dict[str, list | dict] = {} self.src_dict: dict = {} self.errors: list[str] = [] @@ -1273,9 +1281,11 @@ def __init__(self, settings: Settings) -> None: logging.getLogger('').warning("Cosmetic Plandomizer enabled, but no file provided.") self.settings.enable_cosmetic_file = False - self.bgm_groups['favorites'] = CollapseList(self.src_dict.get('bgm_groups', {}).get('favorites', []).copy()) - self.bgm_groups['exclude'] = CollapseList(self.src_dict.get('bgm_groups', {}).get('exclude', []).copy()) - self.bgm_groups['groups'] = AlignedDict(self.src_dict.get('bgm_groups', {}).get('groups', {}).copy(), 1) + self.bgm_groups: BgmGroups = { + 'favorites': CollapseList(self.src_dict.get('bgm_groups', {}).get('favorites', []).copy()), + 'exclude': CollapseList(self.src_dict.get('bgm_groups', {}).get('exclude', []).copy()), + 'groups': AlignedDict(self.src_dict.get('bgm_groups', {}).get('groups', {}).copy(), 1), + } for key, value in self.bgm_groups['groups'].items(): self.bgm_groups['groups'][key] = CollapseList(value.copy()) diff --git a/EntranceShuffle.py b/EntranceShuffle.py index e0a85ee7a..58f0634d9 100644 --- a/EntranceShuffle.py +++ b/EntranceShuffle.py @@ -23,15 +23,15 @@ def set_all_entrances_data(world: World) -> None: - for type, forward_entry, *return_entry in entrance_shuffle_table: + for type, forward_entry, *return_entries in entrance_shuffle_table: forward_entrance = world.get_entrance(forward_entry[0]) forward_entrance.data = forward_entry[1] forward_entrance.type = type forward_entrance.primary = True if type == 'Grotto': forward_entrance.data['index'] = 0x1000 + forward_entrance.data['grotto_id'] - if return_entry: - return_entry = return_entry[0] + if return_entries: + return_entry = return_entries[0] return_entrance = world.get_entrance(return_entry[0]) return_entrance.data = return_entry[1] return_entrance.type = type @@ -61,8 +61,12 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude one_way_entrances += world.get_shufflable_entrances(type=pool_type) valid_one_way_entrances = list(filter(lambda entrance: entrance.name not in exclude, one_way_entrances)) if target_region_names: - return [entrance.get_new_target() for entrance in valid_one_way_entrances - if entrance.connected_region.name in target_region_names] + return [ + entrance.get_new_target() + for entrance in valid_one_way_entrances + if entrance.connected_region is not None + and entrance.connected_region.name in target_region_names + ] return [entrance.get_new_target() for entrance in valid_one_way_entrances] @@ -86,7 +90,7 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude # ZF Zora's Fountain # ZR Zora's River -entrance_shuffle_table = [ +entrance_shuffle_table: list[tuple[str, tuple[str, dict]] | tuple[str, tuple[str, dict], tuple[str, dict]]] = [ ('Dungeon', ('KF Outside Deku Tree -> Deku Tree Lobby', { 'index': 0x0000 }), ('Deku Tree Lobby -> KF Outside Deku Tree', { 'index': 0x0209 })), ('Dungeon', ('Death Mountain -> Dodongos Cavern Beginning', { 'index': 0x0004 }), @@ -423,7 +427,9 @@ def set_entrances(worlds: list[World], savewarps_to_connect: list[tuple[Entrance world.initialize_entrances() for savewarp, replaces in savewarps_to_connect: + assert savewarp.world is not None savewarp.replaces = savewarp.world.get_entrance(replaces) + assert savewarp.replaces.connected_region is not None savewarp.connect(savewarp.replaces.connected_region) for world in worlds: @@ -550,20 +556,20 @@ def shuffle_random_entrances(worlds: list[World]) -> None: for pool_type, entrance_pool in one_way_entrance_pools.items(): # One way entrances are extra entrances that will be connected to entrance positions from a selection of entrance pools if pool_type == 'OverworldOneWay': - valid_target_types = ('WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Extra') - one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types, exclude=['Prelude of Light Warp -> Temple of Time']) + valid_target_types_owow = ('WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Extra') + one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types_owow, exclude=['Prelude of Light Warp -> Temple of Time']) elif pool_type == 'OwlDrop': - valid_target_types = ('WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Extra') - one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types, exclude=['Prelude of Light Warp -> Temple of Time']) + valid_target_types_owl = ('WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Extra') + one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types_owl, exclude=['Prelude of Light Warp -> Temple of Time']) for target in one_way_target_entrance_pools[pool_type]: target.set_rule(lambda state, age=None, **kwargs: age == 'child') elif pool_type == 'Spawn': - valid_target_types = ('Spawn', 'WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Interior', 'SpecialInterior', 'Extra') + valid_target_types_spawn = ('Spawn', 'WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Interior', 'SpecialInterior', 'Extra') # Restrict spawn entrances from linking to regions with no or extremely specific glitchless itemless escapes. - one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types, exclude=['Volvagia Boss Room -> DMC Central Local', 'Bolero of Fire Warp -> DMC Central Local', 'Queen Gohma Boss Room -> KF Outside Deku Tree']) + one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types_spawn, exclude=['Volvagia Boss Room -> DMC Central Local', 'Bolero of Fire Warp -> DMC Central Local', 'Queen Gohma Boss Room -> KF Outside Deku Tree']) elif pool_type == 'WarpSong': - valid_target_types = ('Spawn', 'WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Interior', 'SpecialInterior', 'Extra') - one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types) + valid_target_types_song = ('Spawn', 'WarpSong', 'BlueWarp', 'OwlDrop', 'OverworldOneWay', 'Overworld', 'Interior', 'SpecialInterior', 'Extra') + one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types_song) # Ensure that when trying to place the last entrance of a one way pool, we don't assume the rest of the targets are reachable for target in one_way_target_entrance_pools[pool_type]: target.add_rule((lambda entrances=entrance_pool: (lambda state, **kwargs: any( @@ -697,7 +703,7 @@ def shuffle_one_way_priority_entrances(worlds: list[World], world: World, one_wa retry_count: int = 2) -> list[tuple[Entrance, Entrance]]: while retry_count: retry_count -= 1 - rollbacks = [] + rollbacks: list[tuple[Entrance, Entrance]] = [] try: for key, (regions, types) in one_way_priorities.items(): @@ -732,7 +738,7 @@ def shuffle_entrance_pool(world: World, worlds: list[World], entrance_pool: list while retry_count: retry_count -= 1 - rollbacks = [] + rollbacks: list[tuple[Entrance, Entrance]] = [] try: # Shuffle restrictive entrances first while more regions are available in order to heavily reduce the chances of the placement failing. @@ -756,7 +762,7 @@ def shuffle_entrance_pool(world: World, worlds: list[World], entrance_pool: list except EntranceShuffleError as error: for entrance, target in rollbacks: restore_connections(entrance, target) - logging.getLogger('').info('Failed to place all entrances in a pool for world %d. Will retry %d more times', entrance_pool[0].world.id, retry_count) + logging.getLogger('').info(f'Failed to place all entrances in a pool for {world}. Will retry {retry_count} more times') logging.getLogger('').info('\t%s' % error) if world.settings.custom_seed: @@ -773,7 +779,9 @@ def split_entrances_by_requirements(worlds: list[World], entrances_to_split: lis entrances_to_disconnect = set(assumed_entrances).union(entrance.reverse for entrance in assumed_entrances if entrance.reverse) for entrance in entrances_to_disconnect: if entrance.connected_region: - original_connected_regions[entrance] = entrance.disconnect() + previously_connected = entrance.disconnect() + assert previously_connected is not None + original_connected_regions[entrance] = previously_connected # Generate the states with all assumed entrances disconnected # This ensures no assumed entrances corresponding to those we are shuffling are required in order for an entrance to be reachable as some age/tod @@ -806,6 +814,8 @@ def replace_entrance(worlds: list[World], entrance: Entrance, target: Entrance, if placed_one_way_entrances is None: placed_one_way_entrances = [] try: + if entrance.world is None: + raise EntranceShuffleError('Entrance has no world') check_entrances_compatibility(entrance, target, rollbacks, placed_one_way_entrances) change_connections(entrance, target) validate_world(entrance.world, worlds, entrance, locations_to_ensure_reachable, itempool, placed_one_way_entrances=placed_one_way_entrances) @@ -813,8 +823,7 @@ def replace_entrance(worlds: list[World], entrance: Entrance, target: Entrance, return True except EntranceShuffleError as error: # If the entrance can't be placed there, log a debug message and change the connections back to what they were previously - logging.getLogger('').debug('Failed to connect %s To %s (Reason: %s) [World %d]', - entrance, entrance.connected_region or target.connected_region, error, entrance.world.id) + logging.getLogger('').debug(f'Failed to connect {entrance} To {entrance.connected_region or target.connected_region} (Reason: {error}) [{entrance.world}]') if entrance.connected_region: restore_connections(entrance, target) return False @@ -835,6 +844,9 @@ def place_one_way_priority_entrance(worlds: list[World], world: World, priority_ for entrance in avail_pool: if entrance.replaces: continue + assert entrance.parent_region is not None + assert entrance.type is not None + assert entrance.world is not None # Only allow Adult Spawn as sole Nocturne access if hints != mask. # Otherwise, child access is required here (adult access assumed or guaranteed later). if entrance.parent_region.name == 'Adult Spawn': @@ -877,12 +889,17 @@ def shuffle_entrances(worlds: list[World], entrances: list[Entrance], target_ent break if entrance.connected_region is None: - raise EntranceShuffleError('No more valid entrances to replace with %s in world %d' % (entrance, entrance.world.id)) + raise EntranceShuffleError(f'No more valid entrances to replace with {entrance} in {entrance.world}') # Check and validate that an entrance is compatible to replace a specific target -def check_entrances_compatibility(entrance: Entrance, target: Entrance, rollbacks: list[tuple[Entrance, Entrance]] = (), +def check_entrances_compatibility(entrance: Entrance, target: Entrance, rollbacks: Iterable[tuple[Entrance, Entrance]] = (), placed_one_way_entrances: Optional[list[tuple[Entrance, Entrance]]] = None) -> None: + + if entrance.parent_region is None: + raise EntranceShuffleError('Entrance has no parent region') + if target.connected_region is None: + raise EntranceShuffleError('Target has no connected region') if placed_one_way_entrances is None: placed_one_way_entrances = [] # An entrance shouldn't be connected to its own scene, so we fail in that situation @@ -900,6 +917,7 @@ def check_entrances_compatibility(entrance: Entrance, target: Entrance, rollback for rollback in (*rollbacks, *placed_one_way_entrances): try: placed_entrance = rollback[0] + assert placed_entrance.connected_region is not None if entrance.type == placed_entrance.type and HintArea.at(placed_entrance.connected_region) == hint_area: raise EntranceShuffleError(f'Another {entrance.type} entrance already leads to {hint_area}') except HintAreaNotFound: @@ -925,14 +943,14 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ for entrance in world.get_shufflable_entrances(): if entrance.shuffled: if entrance.replaces: - if entrance.replaces.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.replaces.reverse]): + if entrance.replaces.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[] if entrance.replaces.reverse is None else [entrance.replaces.reverse]): raise EntranceShuffleError('%s is replaced by an entrance with a potential child access' % entrance.replaces.name) - elif entrance.replaces.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.replaces.reverse]): + elif entrance.replaces.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[] if entrance.replaces.reverse is None else [entrance.replaces.reverse]): raise EntranceShuffleError('%s is replaced by an entrance with a potential adult access' % entrance.replaces.name) else: - if entrance.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.reverse]): + if entrance.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[] if entrance.reverse is None else [entrance.reverse]): raise EntranceShuffleError('%s is potentially accessible as child' % entrance.name) - elif entrance.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.reverse]): + elif entrance.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[] if entrance.reverse is None else [entrance.reverse]): raise EntranceShuffleError('%s is potentially accessible as adult' % entrance.name) if locations_to_ensure_reachable: @@ -975,7 +993,7 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ raise EntranceShuffleError('Kak Impas House entrances are not in the same hint area') if (world.shuffle_special_interior_entrances or world.settings.shuffle_overworld_entrances or world.settings.spawn_positions) and \ - (entrance_placed == None or entrance_placed.type in ('SpecialInterior', 'Hideout', 'Overworld', 'OverworldOneWay', 'Spawn', 'WarpSong', 'OwlDrop')): + (entrance_placed is None or entrance_placed.type in ('SpecialInterior', 'Hideout', 'Overworld', 'OverworldOneWay', 'Spawn', 'WarpSong', 'OwlDrop')): # At least one valid starting region with all basic refills should be reachable without using any items at the beginning of the seed # Note this creates new empty states rather than reuse the worlds' states (which already have starting items) no_items_search = Search([State(w) for w in worlds]) @@ -999,7 +1017,7 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ raise EntranceShuffleError('Path to Temple of Time as child is not guaranteed') if (world.shuffle_interior_entrances or world.settings.shuffle_overworld_entrances) and \ - (entrance_placed == None or entrance_placed.type in ('Interior', 'SpecialInterior', 'Hideout', 'Overworld', 'OverworldOneWay', 'Spawn', 'WarpSong', 'OwlDrop')): + (entrance_placed is None or entrance_placed.type in ('Interior', 'SpecialInterior', 'Hideout', 'Overworld', 'OverworldOneWay', 'Spawn', 'WarpSong', 'OwlDrop')): # The Big Poe Shop should always be accessible as adult without the need to use any bottles # This is important to ensure that players can never lock their only bottles by filling them with Big Poes they can't sell # We can use starting items in this check as long as there are no exits requiring the use of a bottle without refills @@ -1013,6 +1031,7 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ for idx1 in range(len(placed_one_way_entrances)): try: entrance1 = placed_one_way_entrances[idx1][0] + assert entrance1.connected_region is not None hint_area1 = HintArea.at(entrance1.connected_region) except HintAreaNotFound: pass @@ -1020,6 +1039,7 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ for idx2 in range(idx1): try: entrance2 = placed_one_way_entrances[idx2][0] + assert entrance2.connected_region is not None if entrance1.type == entrance2.type and hint_area1 == HintArea.at(entrance2.connected_region): raise EntranceShuffleError(f'Multiple {entrance1.type} entrances lead to {hint_area1}') except HintAreaNotFound: @@ -1046,6 +1066,7 @@ def entrance_unreachable_as(entrance: Entrance, age: str, already_checked: Optio # Other entrances such as Interior, Dungeon or Grotto are fine unless they have a parent which is one of the above cases # Recursively check parent entrances to verify that they are also not reachable as the wrong age + assert entrance.parent_region is not None for parent_entrance in entrance.parent_region.entrances: if parent_entrance in already_checked: continue unreachable = entrance_unreachable_as(parent_entrance, age, already_checked) @@ -1082,30 +1103,47 @@ def get_entrance_replacing(region: Region, entrance_name: str) -> Optional[Entra # Change connections between an entrance and a target assumed entrance, in order to test the connections afterwards if necessary def change_connections(entrance: Entrance, target_entrance: Entrance) -> None: - entrance.connect(target_entrance.disconnect()) + previously_connected = target_entrance.disconnect() + assert previously_connected is not None + entrance.connect(previously_connected) entrance.replaces = target_entrance.replaces - if entrance.reverse: - target_entrance.replaces.reverse.connect(entrance.reverse.assumed.disconnect()) + if entrance.reverse is not None: + assert target_entrance.replaces is not None + assert target_entrance.replaces.reverse is not None + assert entrance.reverse.assumed is not None + previously_connected_reverse = entrance.reverse.assumed.disconnect() + assert previously_connected_reverse is not None + target_entrance.replaces.reverse.connect(previously_connected_reverse) target_entrance.replaces.reverse.replaces = entrance.reverse # Restore connections between an entrance and a target assumed entrance def restore_connections(entrance: Entrance, target_entrance: Entrance) -> None: - target_entrance.connect(entrance.disconnect()) + previously_connected = entrance.disconnect() + assert previously_connected is not None + target_entrance.connect(previously_connected) entrance.replaces = None - if entrance.reverse: - entrance.reverse.assumed.connect(target_entrance.replaces.reverse.disconnect()) + if entrance.reverse is not None: + assert entrance.reverse.assumed is not None + assert target_entrance.replaces is not None + assert target_entrance.replaces.reverse is not None + previously_connected_reverse = target_entrance.replaces.reverse.disconnect() + assert previously_connected_reverse is not None + entrance.reverse.assumed.connect(previously_connected_reverse) target_entrance.replaces.reverse.replaces = None # Confirm the replacement of a target entrance by a new entrance, logging the new connections and completely deleting the target entrances def confirm_replacement(entrance: Entrance, target_entrance: Entrance) -> None: delete_target_entrance(target_entrance) - logging.getLogger('').debug('Connected %s To %s [World %d]', entrance, entrance.connected_region, entrance.world.id) - if entrance.reverse: + logging.getLogger('').debug(f'Connected {entrance} To {entrance.connected_region} [{entrance.world}]') + if entrance.reverse is not None: + assert target_entrance.replaces is not None + assert entrance.reverse.assumed is not None replaced_reverse = target_entrance.replaces.reverse + assert replaced_reverse is not None delete_target_entrance(entrance.reverse.assumed) - logging.getLogger('').debug('Connected %s To %s [World %d]', replaced_reverse, replaced_reverse.connected_region, replaced_reverse.world.id) + logging.getLogger('').debug(f'Connected {replaced_reverse} To {replaced_reverse.connected_region} [{replaced_reverse.world}]') # Delete an assumed target entrance, by disconnecting it if needed and removing it from its parent region diff --git a/Fill.py b/Fill.py index ff1229c9c..0884f7e05 100644 --- a/Fill.py +++ b/Fill.py @@ -27,7 +27,7 @@ class FillError(ShuffleError): # Places all items into the world -def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[list[Location]] = None) -> None: +def distribute_items_restrictive(worlds: list[World]) -> None: if worlds[0].settings.shuffle_song_items == 'song': song_location_names = location_groups['Song'] elif worlds[0].settings.shuffle_song_items == 'dungeon': @@ -54,13 +54,15 @@ def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[l shop_locations = [location for world in worlds for location in world.get_unfilled_locations() if location.type == 'Shop' and location.price is None] - # If not passed in, then get a shuffled list of locations to fill in - if not fill_locations: - fill_locations = [ - location for world in worlds for location in world.get_unfilled_locations() - if location not in song_locations - and location not in shop_locations - and not location.type.startswith('Hint')] + # get a shuffled list of locations to fill in + fill_locations = [ + location + for world in worlds + for location in world.get_unfilled_locations() + if location not in song_locations + and location not in shop_locations + and not location.type.startswith('Hint') + ] world_states = [world.state for world in worlds] # Generate the itempools @@ -97,7 +99,7 @@ def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[l junk_items = remove_junk_items.copy() junk_items.remove('Ice Trap') major_items = [name for name, item in ItemInfo.items.items() if item.type == 'Item' and item.advancement and item.index is not None] - fake_items = [] + fake_items: list[Item] = [] if worlds[0].settings.ice_trap_appearance == 'major_only': model_items = [item for item in itempool if item.majoritem] if len(model_items) == 0: # All major items were somehow removed from the pool (can happen in plando) @@ -145,9 +147,14 @@ def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[l # If some dungeons are supposed to be empty, fill them with useless items. if worlds[0].settings.empty_dungeons_mode != 'none': - empty_locations = [location for location in fill_locations - if location.world.empty_dungeons[HintArea.at(location).dungeon_name].empty] + empty_locations = [] + for location in fill_locations: + assert location.world is not None + dungeon_name = HintArea.at(location).dungeon_name + if dungeon_name is not None and location.world.empty_dungeons[dungeon_name].empty: + empty_locations.append(location) for location in empty_locations: + assert location.world is not None fill_locations.remove(location) location.world.hint_type_overrides['sometimes'].append(location.name) location.world.hint_type_overrides['random'].append(location.name) @@ -209,9 +216,9 @@ def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[l # Log unplaced item/location warnings for item in progitempool + prioitempool + restitempool: - logger.error('Unplaced Items: %s [World %d]' % (item.name, item.world.id)) + logger.error(f'Unplaced Items: {item.name} [{item.world}]') for location in fill_locations: - logger.error('Unfilled Locations: %s [World %d]' % (location.name, location.world.id)) + logger.error(f'Unfilled Locations: {location.name} [{location.world}]') if progitempool + prioitempool + restitempool: raise FillError('Not all items are placed.') @@ -225,9 +232,9 @@ def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[l worlds[0].settings.distribution.cloak(worlds, [cloakable_locations], [all_models]) for world in worlds: - for location in world.get_filled_locations(): + for location in world.get_filled_locations(lambda item: item.advancement): # Get the maximum amount of wallets required to purchase an advancement item. - if world.maximum_wallets < 3 and location.price and location.item.advancement: + if world.maximum_wallets < 3 and location.price: if location.price > 500: world.maximum_wallets = 3 elif world.maximum_wallets < 2 and location.price > 200: @@ -306,6 +313,7 @@ def fill_dungeon_unique_item(worlds: list[World], search: Search, fill_locations # Sort major items in such a way that they are placed first if dungeon restricted. # There still won't be enough locations for small keys in one item per dungeon mode, though. for item in list(major_items): + assert item.world is not None if not item.world.get_region('Root').can_fill(item): major_items.remove(item) major_items.append(item) @@ -330,6 +338,7 @@ def fill_dungeon_unique_item(worlds: list[World], search: Search, fill_locations # Error out if we have any items that won't be placeable in the overworld left. for item in major_items: + assert item.world is not None if not item.world.get_region('Root').can_fill(item): raise FillError(f"No more dungeon locations available for {item.name} to be placed with 'Dungeons Have One Major Item' enabled. To fix this, either disable 'Dungeons Have One Major Item' or enable some settings that add more locations for shuffled items in the overworld.") @@ -344,8 +353,8 @@ def fill_ownworld_restrictive(worlds: list[World], search: Search, locations: li unplaced_prizes = [item for item in ownpool if item.name not in placed_prizes] empty_locations = [loc for loc in locations if loc.item is None] - prizepool_dict = {world.id: [item for item in unplaced_prizes if item.world.id == world.id] for world in worlds} - prize_locs_dict = {world.id: [loc for loc in empty_locations if loc.world.id == world.id] for world in worlds} + prizepool_dict = {world.id: [item for item in unplaced_prizes if item.world is not None and item.world.id == world.id] for world in worlds} + prize_locs_dict = {world.id: [loc for loc in empty_locations if loc.world is not None and loc.world.id == world.id] for world in worlds} # Shop item being sent in to this method are tied to their own world. # Therefore, let's do this one world at a time. We do this to help @@ -413,6 +422,7 @@ def fill_restrictive(worlds: list[World], base_search: Search, locations: list[L # get an item and remove it from the itempool item_to_place = itempool.pop() + assert item_to_place.world is not None if item_to_place.priority: l2cations = [l for l in locations if l.can_fill_fast(item_to_place)] elif item_to_place.majoritem: @@ -445,6 +455,7 @@ def fill_restrictive(worlds: list[World], base_search: Search, locations: list[L # in the world we are placing it (possibly checking for reachability) spot_to_fill = None for location in l2cations: + assert location.world is not None if location.can_fill(max_search.state_list[location.world.id], item_to_place, perform_access_check): # for multiworld, make it so that the location is also reachable # in the world the item is for. This is to prevent early restrictions @@ -493,6 +504,7 @@ def fill_restrictive(worlds: list[World], base_search: Search, locations: list[L raise FillError(f'Game unbeatable: No more spots to place {item_to_place} [World {item_to_place.world.id + 1}] from {len(l2cations)} locations ({len(locations)} total); {len(itempool)} other items left to place, plus {len(unplaced_items)} skipped') # Place the item in the world and continue + assert spot_to_fill.world is not None spot_to_fill.world.push_item(spot_to_fill, item_to_place) locations.remove(spot_to_fill) @@ -532,6 +544,7 @@ def fill_restrictive_fast(worlds: list[World], locations: list[Location], itempo break # Place the item in the world and continue + assert spot_to_fill.world is not None spot_to_fill.world.push_item(spot_to_fill, item_to_place) locations.remove(spot_to_fill) @@ -543,5 +556,6 @@ def fast_fill(locations: list[Location], itempool: list[Item]) -> None: random.shuffle(locations) while itempool and locations: spot_to_fill = locations.pop() + assert spot_to_fill.world is not None item_to_place = itempool.pop() spot_to_fill.world.push_item(spot_to_fill, item_to_place) diff --git a/Goals.py b/Goals.py index ea06972e1..0915312ba 100644 --- a/Goals.py +++ b/Goals.py @@ -2,7 +2,7 @@ import sys from collections import defaultdict from collections.abc import Iterable, Collection -from typing import TYPE_CHECKING, Optional, Any +from typing import TYPE_CHECKING, Optional, Any, TypedDict from HintList import goalTable, get_hint_group, hint_exclusions from ItemList import item_table @@ -21,7 +21,6 @@ from World import World RequiredLocations: TypeAlias = "dict[str, dict[str, dict[int, list[tuple[Location, int, int]]]] | list[Location]]" -GoalItem: TypeAlias = "dict[str, str | int | bool]" validColors: list[str] = [ 'White', @@ -35,8 +34,15 @@ ] +class GoalItem(TypedDict): + name: str + quantity: int + minimum: int + hintable: bool + + class Goal: - def __init__(self, world: World, name: str, hint_text: str | dict[str, str], color: str, items: Optional[list[dict[str, Any]]] = None, + def __init__(self, world: World, name: str, hint_text: str | dict[str, str], color: str, items: Optional[list[GoalItem]] = None, locations=None, lock_locations=None, lock_entrances: Optional[list[str]] = None, required_locations=None, create_empty: bool = False) -> None: # early exit if goal initialized incorrectly if not items and not locations and not create_empty: @@ -75,7 +81,7 @@ def get_item(self, item: str) -> GoalItem: def requires(self, item: str) -> bool: # Prevent direct hints for certain items that can have many duplicates, such as tokens and Triforce Pieces names = [item] - if item_table[item][3] is not None and 'alias' in item_table[item][3]: + if 'alias' in item_table[item][3]: names.append(item_table[item][3]['alias'][0]) return any(i['name'] in names and not i['hintable'] for i in self.items) @@ -85,11 +91,11 @@ def __repr__(self) -> str: class GoalCategory: def __init__(self, name: str, priority: int, goal_count: int = 0, minimum_goals: int = 0, - lock_locations=None, lock_entrances: list[str] = None) -> None: + lock_locations=None, lock_entrances: Optional[list[str]] = None) -> None: self.name: str = name self.priority: int = priority self.lock_locations = lock_locations # Unused? - self.lock_entrances: list[str] = lock_entrances + self.lock_entrances: Optional[list[str]] = lock_entrances self.goals: list[Goal] = [] self.goal_count: int = goal_count self.minimum_goals: int = minimum_goals @@ -148,11 +154,12 @@ def update_reachable_goals(self, starting_search: Search, full_search: Search) - def replace_goal_names(worlds: list[World]) -> None: for world in worlds: - bosses = [location for location in world.get_filled_locations() if location.item.type == 'DungeonReward'] + bosses = [location for location in world.get_filled_locations(lambda item: item.type == 'DungeonReward')] for cat_name, category in world.goal_categories.items(): for goal in category.goals: if isinstance(goal.hint_text, dict): for boss in bosses: + assert boss.item is not None if boss.item.name == goal.hint_text['replace']: flavorText, clearText, color = goalTable[boss.name] if world.settings.clearer_hints: @@ -171,11 +178,15 @@ def update_goal_items(spoiler: Spoiler) -> None: # item_locations: only the ones that should appear as "required"/WotH all_locations = [location for world in worlds for location in world.get_filled_locations()] # Set to test inclusion against - item_locations = {location for location in all_locations if location.item.majoritem and not location.locked} + item_locations = set() + for location in all_locations: + assert location.item is not None + if location.item.majoritem and not location.locked: + item_locations.add(location) # required_locations[category.name][goal.name][world_id] = [...] required_locations: RequiredLocations = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) - priority_locations = {world.id: {} for world in worlds} + priority_locations: dict[int, dict[str, str]] = {world.id: {} for world in worlds} # rebuild hint exclusion list for world in worlds: diff --git a/Gui.py b/Gui.py index e7c7efb58..19ae6cb0f 100755 --- a/Gui.py +++ b/Gui.py @@ -39,10 +39,12 @@ def gui_main() -> None: def version_check(name: str, version: str, url: str) -> None: - try: - process = subprocess.Popen([shutil.which(name.lower()), "--version"], stdout=subprocess.PIPE) - except Exception as ex: - raise VersionError('{name} is not installed. Please install {name} {version} or later'.format(name=name, version=version), url) + path = shutil.which(name.lower()) + if path is None: + raise VersionError(f'{name} is not installed. Please install {name} {version} or later', url) + else: + process = subprocess.Popen([path, "--version"], stdout=subprocess.PIPE) + assert process.stdout is not None while True: line = str(process.stdout.readline().strip(), 'UTF-8') diff --git a/HintList.py b/HintList.py index 4ec8cd472..d14f96a5b 100644 --- a/HintList.py +++ b/HintList.py @@ -70,13 +70,13 @@ def get_hint_group(group: str, world: World) -> list[Hint]: hint = get_hint(name, world.settings.clearer_hints) if hint.name in world.always_hints and group == 'always': - hint.type = 'always' + hint.type = ['always'] if group == 'dual_always' and hint.name in conditional_dual_always and conditional_dual_always[hint.name](world): - hint.type = 'dual_always' + hint.type = ['dual_always'] if group == 'entrance_always' and hint.name in conditional_entrance_always and conditional_entrance_always[hint.name](world): - hint.type = 'entrance_always' + hint.type = ['entrance_always'] conditional_keep = True type_append = False @@ -86,13 +86,13 @@ def get_hint_group(group: str, world: World) -> list[Hint]: # Hint inclusion override from distribution if (group in world.added_hint_types or group in world.item_added_hint_types): if hint.name in world.added_hint_types[group]: - hint.type = group + hint.type = [group] type_append = True if name_is_location(name, hint.type, world): location = world.get_location(name) for i in world.item_added_hint_types[group]: if i == location.item.name: - hint.type = group + hint.type = [group] type_append = True for i in world.item_hint_type_overrides[group]: if i == location.item.name: @@ -1875,18 +1875,17 @@ def tokens_required_by_settings(world: World) -> int: # This specifies which hints will never appear due to either having known or known useless contents or due to the locations not existing. +HINT_EXCLUSION_CACHE: dict[int, list[str]] = {} def hint_exclusions(world: World, clear_cache: bool = False) -> list[str]: - exclusions: dict[int, list[str]] = hint_exclusions.exclusions + if not clear_cache and world.id in HINT_EXCLUSION_CACHE: + return HINT_EXCLUSION_CACHE[world.id] - if not clear_cache and world.id in exclusions: - return exclusions[world.id] - - exclusions[world.id] = [] - exclusions[world.id].extend(world.settings.disabled_locations) + HINT_EXCLUSION_CACHE[world.id] = [] + HINT_EXCLUSION_CACHE[world.id].extend(world.settings.disabled_locations) for location in world.get_locations(): if location.locked: - exclusions[world.id].append(location.name) + HINT_EXCLUSION_CACHE[world.id].append(location.name) world_location_names = [ location.name for location in world.get_locations()] @@ -1915,14 +1914,11 @@ def hint_exclusions(world: World, clear_cache: bool = False) -> list[str]: if location not in world_location_names or world.get_location(location).locked: exclude_hint = True if exclude_hint: - exclusions[world.id].append(hint.name) + HINT_EXCLUSION_CACHE[world.id].append(hint.name) else: - if hint.name not in world_location_names and hint.name not in exclusions[world.id]: - exclusions[world.id].append(hint.name) - return exclusions[world.id] - - -hint_exclusions.exclusions = {} + if hint.name not in world_location_names and hint.name not in HINT_EXCLUSION_CACHE[world.id]: + HINT_EXCLUSION_CACHE[world.id].append(hint.name) + return HINT_EXCLUSION_CACHE[world.id] def name_is_location(name: str, hint_type: str | Collection[str], world: World) -> bool: @@ -1938,4 +1934,4 @@ def name_is_location(name: str, hint_type: str | Collection[str], world: World) def clear_hint_exclusion_cache() -> None: - hint_exclusions.exclusions.clear() + HINT_EXCLUSION_CACHE.clear() diff --git a/Hints.py b/Hints.py index f21d3fb13..10a975ef7 100644 --- a/Hints.py +++ b/Hints.py @@ -28,15 +28,15 @@ if TYPE_CHECKING: from Entrance import Entrance - from Goals import GoalCategory + from Goals import Goal, GoalCategory from Location import Location from Spoiler import Spoiler from World import World Spot: TypeAlias = "Entrance | Location | Region" HintReturn: TypeAlias = "Optional[tuple[GossipText, Optional[list[Location]]]]" -HintFunc: TypeAlias = "Callable[[Spoiler, World, set[str]], HintReturn]" -BarrenFunc: TypeAlias = "Callable[[Spoiler, World, set[str], set[str]], HintReturn]" +HintFunc: TypeAlias = "Callable[[Spoiler, World, set[HintArea | str]], HintReturn]" +BarrenFunc: TypeAlias = "Callable[[Spoiler, World, set[HintArea | str], set[HintArea | str]], HintReturn]" bingoBottlesForHints: set[str] = { "Bottle", "Bottle with Red Potion", "Bottle with Green Potion", "Bottle with Blue Potion", @@ -68,9 +68,9 @@ class RegionRestriction(Enum): - NONE = 0, - DUNGEON = 1, - OVERWORLD = 2, + NONE = 0 + DUNGEON = 1 + OVERWORLD = 2 class GossipStone: @@ -182,10 +182,10 @@ def is_restricted_dungeon_item(item: Item) -> bool: def add_hint(spoiler: Spoiler, world: World, groups: list[list[int]], gossip_text: GossipText, count: int, - locations: Optional[list[Location]] = None, force_reachable: bool = False, hint_type: str = None) -> bool: + locations: Optional[list[Location]] = None, force_reachable: bool = False, *, hint_type: str) -> bool: random.shuffle(groups) skipped_groups = [] - duplicates = [] + duplicates: list[list[int]] = [] first = True success = True @@ -298,8 +298,7 @@ def add_hint(spoiler: Spoiler, world: World, groups: list[list[int]], gossip_tex def can_reach_hint(worlds: list[World], hint_location: Location, location: Location) -> bool: - if location is None: - return True + assert location.world is not None old_item = location.item location.item = None @@ -333,7 +332,7 @@ def filter_trailing_space(text: str) -> str: ] -def get_simple_hint_no_prefix(item: Item) -> Hint: +def get_simple_hint_no_prefix(item: Item) -> str: hint = get_hint(item.name, True).text for prefix in hintPrefixes: @@ -420,10 +419,11 @@ def at(spot: Spot, use_alt_hint: bool = False) -> HintArea: if isinstance(spot, Region): original_parent = spot else: + assert spot.parent_region is not None original_parent = spot.parent_region already_checked = [] - spot_queue = [spot] - fallback_spot_queue = [] + spot_queue: list[Spot] = [spot] + fallback_spot_queue: list[Spot] = [] while spot_queue or fallback_spot_queue: if not spot_queue: @@ -435,6 +435,7 @@ def at(spot: Spot, use_alt_hint: bool = False) -> HintArea: if isinstance(current_spot, Region): parent_region = current_spot else: + assert current_spot.parent_region is not None parent_region = current_spot.parent_region if parent_region.hint and (original_parent.name == 'Root' or parent_region.name != 'Root'): @@ -450,7 +451,7 @@ def at(spot: Spot, use_alt_hint: bool = False) -> HintArea: else: spot_queue.append(entrance) - raise HintAreaNotFound('No hint area could be found for %s [World %d]' % (spot, spot.world.id)) + raise HintAreaNotFound(f'No hint area could be found for {spot} [{spot.world}]') @classmethod def for_dungeon(cls, dungeon_name: str) -> Optional[HintArea]: @@ -497,6 +498,7 @@ def is_dungeon(self) -> bool: return self.dungeon_name is not None def is_dungeon_item(self, item: Item) -> bool: + assert item.world is not None for dungeon in item.world.dungeons: if dungeon.name == self.dungeon_name: return dungeon.is_dungeon_item(item) @@ -505,7 +507,7 @@ 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[int] = None) -> str: - if self.is_dungeon and self.dungeon_name: + if self.dungeon_name is not None: text = get_hint(self.dungeon_name, clearer_hints).text else: text = str(self) @@ -531,21 +533,24 @@ def text(self, clearer_hints: bool, preposition: bool = False, world: Optional[i return text -def get_woth_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: - locations = spoiler.required_locations[world.id] - locations = list(filter(lambda location: - location.name not in checked +def get_woth_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: + locations = [ + location + for location in spoiler.required_locations[world.id] + if location.name not in checked and not (world.woth_dungeon >= world.hint_dist_user['dungeons_woth_limit'] and HintArea.at(location).is_dungeon) and location.name not in world.hint_exclusions and location.name not in world.hint_type_overrides['woth'] + and location.item is not None and location.item.name not in world.item_hint_type_overrides['woth'] - and location.item.name not in unHintableWothItems, - locations)) + and location.item.name not in unHintableWothItems + ] if not locations: return None location = random.choice(locations) + assert location.item is not None checked.add(location.name) hint_area = HintArea.at(location) @@ -556,20 +561,29 @@ def get_woth_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintRetu return GossipText('%s is on the way of the hero.' % location_text, ['Light Blue'], [location.name], [location.item.name]), [location] -def get_checked_areas(world: World, checked: set[str]) -> set[HintArea | str]: - def get_area_from_name(check: str) -> HintArea | str: +def get_checked_areas(world: World, checked: set[HintArea | str]) -> set[HintArea]: + def get_area_from_name(check: str) -> Optional[HintArea]: try: location = world.get_location(check) except Exception: - return check + return None # Don't consider dungeons as already hinted from the reward hint on the Temple of Time altar if location.type != 'Boss': # TODO or shuffled dungeon rewards return HintArea.at(location) + return None - return set(get_area_from_name(check) for check in checked) + areas = set() + for check in checked: + if isinstance(check, HintArea): + areas.add(check) + else: + area = get_area_from_name(check) + if area is not None: + areas.add(area) + return areas -def get_goal_category(spoiler: Spoiler, world: World, goal_categories: dict[str, GoalCategory]) -> GoalCategory: +def get_goal_category(spoiler: Spoiler, world: World, goal_categories: dict[str, GoalCategory]) -> Optional[GoalCategory]: cat_sizes = [] cat_names = [] zero_weights = True @@ -604,7 +618,7 @@ def get_goal_category(spoiler: Spoiler, world: World, goal_categories: dict[str, return goal_category -def get_goal_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_goal_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: goal_category = get_goal_category(spoiler, world, world.goal_categories) # check if no goals were generated (and thus no categories available) @@ -612,9 +626,8 @@ def get_goal_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintRetu return None goals = goal_category.goals - category_locations = [] zero_weights = True - required_location_reverse_map = defaultdict(list) + required_location_reverse_map: defaultdict[Location, list[tuple[Goal, int]]] = defaultdict(list) # Filters Goal.required_locations to those still eligible to be hinted. hintable_required_locations_filter = (lambda required_location: @@ -655,6 +668,7 @@ def get_goal_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintRetu goals = goal_category.goals location, goal_list = random.choice(list(required_location_reverse_map.items())) + assert location.item is not None goal, world_id = random.choice(goal_list) checked.add(location.name) @@ -694,10 +708,7 @@ def get_goal_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintRetu return GossipText('%s is on %s %s.' % (location_text, player_text, goal_text), ['Light Blue', goal.color], [location.name], [location.item.name]), [location] -def get_barren_hint(spoiler: Spoiler, world: World, checked: set[str], all_checked: set[str]) -> HintReturn: - if not hasattr(world, 'get_barren_hint_prev'): - world.get_barren_hint_prev = RegionRestriction.NONE - +def get_barren_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn: checked_areas = get_checked_areas(world, checked) areas = list(filter(lambda area: area not in checked_areas @@ -757,9 +768,12 @@ def is_not_checked(locations: Iterable[Location], checked: set[HintArea | str]) return not any(location.name in checked or HintArea.at(location) in checked for location in locations) -def get_good_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: - locations = list(filter(lambda location: - is_not_checked([location], checked) +def get_good_item_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: + locations = [ + location + for location in world.get_filled_locations() + if is_not_checked([location], checked) + and location.item is not None and ((location.item.majoritem and location.item.name not in unHintableWothItems) or location.name in world.added_hint_types['item'] @@ -767,12 +781,13 @@ def get_good_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> Hin and not location.locked and location.name not in world.hint_exclusions and location.name not in world.hint_type_overrides['item'] - and location.item.name not in world.item_hint_type_overrides['item'], - world.get_filled_locations())) + and location.item.name not in world.item_hint_type_overrides['item'] + ] if not locations: return None location = random.choice(locations) + assert location.item is not None checked.add(location.name) item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text @@ -785,7 +800,7 @@ def get_good_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> Hin return GossipText('#%s# can be found %s.' % (item_text, location_text), ['Green', 'Red'], [location.name], [location.item.name]), [location] -def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: if len(world.named_item_pool) == 0: logger = logging.getLogger('') logger.info("Named item hint requested, but pool is empty.") @@ -798,6 +813,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> location for location in world.get_filled_locations() if (is_not_checked([location], checked) and location.name not in world.hint_exclusions + and location.item is not None and location.item.name in bingoBottlesForHints and not location.locked and location.name not in world.hint_type_overrides['named-item'] @@ -808,6 +824,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> location for location in world.get_filled_locations() if (is_not_checked([location], checked) and location.name not in world.hint_exclusions + and location.item is not None and location.item.name == itemname and not location.locked and location.name not in world.hint_type_overrides['named-item'] @@ -828,6 +845,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> return None location = random.choice(locations) + assert location.item is not None checked.add(location.name) item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text @@ -861,6 +879,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> always_locations = [] for hint, id in always_hints: location = worlds[id].get_location(hint.name) + assert location.item is not None if location.item.name in bingoBottlesForHints and world.settings.hint_dist == 'bingo': always_item = 'Bottle' else: @@ -909,6 +928,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> return None location = random.choice(locations) + assert location.item is not None checked.add(location.name) item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text @@ -924,9 +944,10 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) -> return GossipText('#%s# can be found %s.' % (item_text, location_text), ['Green', 'Red'], [location.name], [location.item.name]), [location] -def get_random_location_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_random_location_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: locations = list(filter(lambda location: is_not_checked([location], checked) + and location.item is not None and location.item.type not in ('Drop', 'Event', 'Shop', 'DungeonReward') and not is_restricted_dungeon_item(location.item) and not location.locked @@ -938,6 +959,7 @@ def get_random_location_hint(spoiler: Spoiler, world: World, checked: set[str]) return None location = random.choice(locations) + assert location.item is not None checked.add(location.name) item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text @@ -950,7 +972,7 @@ def get_random_location_hint(spoiler: Spoiler, world: World, checked: set[str]) return GossipText('#%s# can be found %s.' % (item_text, location_text), ['Green', 'Red'], [location.name], [location.item.name]), [location] -def get_specific_hint(spoiler: Spoiler, world: World, checked: set[str], hint_type: str) -> HintReturn: +def get_specific_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], hint_type: str) -> HintReturn: hint_group = get_hint_group(hint_type, world) hint_group = list(filter(lambda hint: is_not_checked([world.get_location(hint.name)], checked), hint_group)) if not hint_group: @@ -990,23 +1012,23 @@ def get_specific_hint(spoiler: Spoiler, world: World, checked: set[str], hint_ty return GossipText('%s #%s#.' % (location_text, item_text), ['Red', 'Green'], [location.name], [location.item.name]), [location] -def get_sometimes_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_sometimes_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'sometimes') -def get_song_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_song_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'song') -def get_overworld_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_overworld_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'overworld') -def get_dungeon_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_dungeon_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'dungeon') -def get_random_multi_hint(spoiler: Spoiler, world: World, checked: set[str], hint_type: str) -> HintReturn: +def get_random_multi_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], hint_type: str) -> HintReturn: hint_group = get_hint_group(hint_type, world) multi_hints = list(filter(lambda hint: is_not_checked([world.get_location(location) for location in get_multi( hint.name).locations], checked), hint_group)) @@ -1034,7 +1056,7 @@ def get_random_multi_hint(spoiler: Spoiler, world: World, checked: set[str], hin return get_specific_multi_hint(spoiler, world, checked, hint) -def get_specific_multi_hint(spoiler: Spoiler, world: World, checked: set[str], hint: Hint) -> HintReturn: +def get_specific_multi_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], hint: Hint) -> HintReturn: multi = get_multi(hint.name) locations = [world.get_location(location) for location in multi.locations] @@ -1063,11 +1085,11 @@ def get_specific_multi_hint(spoiler: Spoiler, world: World, checked: set[str], h return GossipText(gossip_string % tuple(text_segments), colors, [location.name for location in locations], [item.name for item in items]), locations -def get_dual_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_dual_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: return get_random_multi_hint(spoiler, world, checked, 'dual') -def get_entrance_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_entrance_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: if not world.entrance_shuffle: return None @@ -1103,7 +1125,7 @@ def get_entrance_hint(spoiler: Spoiler, world: World, checked: set[str]) -> Hint return GossipText('%s %s.' % (entrance_text, region_text), ['Green', 'Light Blue']), None -def get_junk_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_junk_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: hints = get_hint_group('junk', world) hints = list(filter(lambda hint: hint.name not in checked, hints)) if not hints: @@ -1115,7 +1137,7 @@ def get_junk_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintRetu return GossipText(hint.text, prefix=''), None -def get_important_check_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn: +def get_important_check_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str]) -> HintReturn: top_level_locations = [] for location in world.get_filled_locations(): if (HintArea.at(location).text(world.settings.clearer_hints) not in top_level_locations @@ -1125,6 +1147,7 @@ def get_important_check_hint(spoiler: Spoiler, world: World, checked: set[str]) hint_loc = random.choice(top_level_locations) item_count = 0 for location in world.get_filled_locations(): + assert location.item is not None region = HintArea.at(location).text(world.settings.clearer_hints) if region == hint_loc: if (location.item.majoritem @@ -1242,7 +1265,7 @@ def always_named_item(world: World, locations: Iterable[Location]): def build_gossip_hints(spoiler: Spoiler, worlds: list[World]) -> None: - checked_locations = dict() + checked_locations: dict[int, set[HintArea | str]] = dict() # Add misc. item hint locations to "checked" locations if the respective hint is reachable without the hinted item. for world in worlds: for location in world.hinted_dungeon_reward_locations.values(): @@ -1272,7 +1295,7 @@ def build_gossip_hints(spoiler: Spoiler, worlds: list[World]) -> None: # builds out general hints based on location and whether an item is required or not -def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations: Optional[set[str]] = None) -> None: +def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations: Optional[set[HintArea | str]] = None) -> None: world.barren_dungeon = 0 world.woth_dungeon = 0 diff --git a/IconManip.py b/IconManip.py index 919ebc834..43c82efe4 100644 --- a/IconManip.py +++ b/IconManip.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from Rom import Rom -RGBValues: TypeAlias = "MutableSequence[MutableSequence[int]]" +RGBValues: TypeAlias = "list[list[int]]" # TODO diff --git a/Item.py b/Item.py index 429a32cac..f0381ea19 100644 --- a/Item.py +++ b/Item.py @@ -30,7 +30,7 @@ def __init__(self, name: str = '', event: bool = False) -> None: item_type = 'Event' progressive = True item_id = None - special = None + special: dict[str, Any] = {} else: (item_type, progressive, item_id, special) = item_table[name] @@ -38,7 +38,7 @@ def __init__(self, name: str = '', event: bool = False) -> None: self.advancement: bool = (progressive is True) self.priority: bool = (progressive is False) self.type: str = item_type - self.special: dict[str, Any] = special or {} + self.special: dict[str, Any] = special self.index: Optional[int] = item_id self.price: Optional[int] = self.special.get('price', None) self.bottle: bool = self.special.get('bottle', False) diff --git a/ItemList.py b/ItemList.py index 22ecf1f1a..116f7074f 100644 --- a/ItemList.py +++ b/ItemList.py @@ -9,19 +9,19 @@ # special "upgrade_ids" correspond to the item IDs in item_table.c for all of the upgrade tiers # of that item. # -item_table: dict[str, tuple[str, Optional[bool], Optional[int], Optional[dict[str, Any]]]] = { +item_table: dict[str, tuple[str, Optional[bool], Optional[int], dict[str, Any]]] = { 'Bombs (5)': ('Item', None, 0x0001, {'junk': 8}), 'Deku Nuts (5)': ('Item', None, 0x0002, {'junk': 5}), - 'Bombchus (10)': ('Item', True, 0x0003, None), - 'Boomerang': ('Item', True, 0x0006, None), + 'Bombchus (10)': ('Item', True, 0x0003, {}), + 'Boomerang': ('Item', True, 0x0006, {}), 'Deku Stick (1)': ('Item', None, 0x0007, {'junk': 5}), - 'Lens of Truth': ('Item', True, 0x000A, None), - 'Megaton Hammer': ('Item', True, 0x000D, None), + 'Lens of Truth': ('Item', True, 0x000A, {}), + 'Megaton Hammer': ('Item', True, 0x000D, {}), 'Cojiro': ('Item', True, 0x000E, {'trade': True}), 'Bottle': ('Item', True, 0x000F, {'bottle': float('Inf')}), - 'Blue Potion': ('Item', True, 0x0012, None), # distinct from shop item + 'Blue Potion': ('Item', True, 0x0012, {}), # distinct from shop item 'Bottle with Milk': ('Item', True, 0x0014, {'bottle': float('Inf')}), - 'Rutos Letter': ('Item', True, 0x0015, None), + 'Rutos Letter': ('Item', True, 0x0015, {}), 'Deliver Letter': ('Item', True, None, {'bottle': float('Inf')}), 'Sell Big Poe': ('Item', True, None, {'bottle': float('Inf')}), 'Magic Bean': ('Item', True, 0x0016, {'progressive': 10}), @@ -41,22 +41,22 @@ 'Eyeball Frog': ('Item', True, 0x0024, {'trade': True}), 'Eyedrops': ('Item', True, 0x0025, {'trade': True}), 'Claim Check': ('Item', True, 0x0026, {'trade': True}), - 'Kokiri Sword': ('Item', True, 0x0027, None), - 'Giants Knife': ('Item', None, 0x0028, None), - 'Deku Shield': ('Item', None, 0x0029, None), - 'Hylian Shield': ('Item', None, 0x002A, None), - 'Mirror Shield': ('Item', True, 0x002B, None), - 'Goron Tunic': ('Item', True, 0x002C, None), - 'Zora Tunic': ('Item', True, 0x002D, None), - 'Iron Boots': ('Item', True, 0x002E, None), - 'Hover Boots': ('Item', True, 0x002F, None), - 'Stone of Agony': ('Item', True, 0x0039, None), - 'Gerudo Membership Card': ('Item', True, 0x003A, None), + 'Kokiri Sword': ('Item', True, 0x0027, {}), + 'Giants Knife': ('Item', None, 0x0028, {}), + 'Deku Shield': ('Item', None, 0x0029, {}), + 'Hylian Shield': ('Item', None, 0x002A, {}), + 'Mirror Shield': ('Item', True, 0x002B, {}), + 'Goron Tunic': ('Item', True, 0x002C, {}), + 'Zora Tunic': ('Item', True, 0x002D, {}), + 'Iron Boots': ('Item', True, 0x002E, {}), + 'Hover Boots': ('Item', True, 0x002F, {}), + 'Stone of Agony': ('Item', True, 0x0039, {}), + 'Gerudo Membership Card': ('Item', True, 0x003A, {}), 'Heart Container': ('Item', True, 0x003D, {'alias': ('Piece of Heart', 4), 'progressive': float('Inf')}), 'Piece of Heart': ('Item', True, 0x003E, {'progressive': float('Inf')}), - 'Boss Key': ('BossKey', True, 0x003F, None), - 'Compass': ('Compass', None, 0x0040, None), - 'Map': ('Map', None, 0x0041, None), + 'Boss Key': ('BossKey', True, 0x003F, {}), + 'Compass': ('Compass', None, 0x0040, {}), + 'Map': ('Map', None, 0x0041, {}), 'Small Key': ('SmallKey', True, 0x0042, {'progressive': float('Inf')}), 'Weird Egg': ('Item', True, 0x0047, {'trade': True}), 'Recovery Heart': ('Item', None, 0x0048, {'junk': 0}), @@ -66,47 +66,47 @@ 'Rupee (1)': ('Item', None, 0x004C, {'junk': -1}), 'Rupees (5)': ('Item', None, 0x004D, {'junk': 10}), 'Rupees (20)': ('Item', None, 0x004E, {'junk': 4}), - 'Milk': ('Item', None, 0x0050, None), + 'Milk': ('Item', None, 0x0050, {}), 'Goron Mask': ('Item', None, 0x0051, {'trade': True, 'object': 0x0150}), 'Zora Mask': ('Item', None, 0x0052, {'trade': True, 'object': 0x0151}), 'Gerudo Mask': ('Item', None, 0x0053, {'trade': True, 'object': 0x0152}), 'Rupees (50)': ('Item', None, 0x0055, {'junk': 1}), 'Rupees (200)': ('Item', None, 0x0056, {'junk': 0}), - 'Biggoron Sword': ('Item', None, 0x0057, None), - 'Fire Arrows': ('Item', True, 0x0058, None), - 'Ice Arrows': ('Item', True, 0x0059, None), - 'Blue Fire Arrows': ('Item', True, 0x0059, None), - 'Light Arrows': ('Item', True, 0x005A, None), + 'Biggoron Sword': ('Item', None, 0x0057, {}), + 'Fire Arrows': ('Item', True, 0x0058, {}), + 'Ice Arrows': ('Item', True, 0x0059, {}), + 'Blue Fire Arrows': ('Item', True, 0x0059, {}), + 'Light Arrows': ('Item', True, 0x005A, {}), 'Gold Skulltula Token': ('Token', True, 0x005B, {'progressive': float('Inf')}), - 'Dins Fire': ('Item', True, 0x005C, None), - 'Nayrus Love': ('Item', True, 0x005E, None), - 'Farores Wind': ('Item', True, 0x005D, None), + 'Dins Fire': ('Item', True, 0x005C, {}), + 'Nayrus Love': ('Item', True, 0x005E, {}), + 'Farores Wind': ('Item', True, 0x005D, {}), 'Deku Nuts (10)': ('Item', None, 0x0064, {'junk': 0}), 'Bomb (1)': ('Item', None, 0x0065, {'junk': -1}), 'Bombs (10)': ('Item', None, 0x0066, {'junk': 2}), 'Bombs (20)': ('Item', None, 0x0067, {'junk': 0}), 'Deku Seeds (30)': ('Item', None, 0x0069, {'junk': 5}), - 'Bombchus (5)': ('Item', True, 0x006A, None), - 'Bombchus (20)': ('Item', True, 0x006B, None), + 'Bombchus (5)': ('Item', True, 0x006A, {}), + 'Bombchus (20)': ('Item', True, 0x006B, {}), 'Small Key (Treasure Chest Game)': ('TCGSmallKey', True, 0x0071, {'progressive': float('Inf')}), - 'Rupee (Treasure Chest Game) (1)': ('Item', None, 0x0072, None), - 'Rupees (Treasure Chest Game) (5)': ('Item', None, 0x0073, None), - 'Rupees (Treasure Chest Game) (20)': ('Item', None, 0x0074, None), - 'Rupees (Treasure Chest Game) (50)': ('Item', None, 0x0075, None), + 'Rupee (Treasure Chest Game) (1)': ('Item', None, 0x0072, {}), + 'Rupees (Treasure Chest Game) (5)': ('Item', None, 0x0073, {}), + 'Rupees (Treasure Chest Game) (20)': ('Item', None, 0x0074, {}), + 'Rupees (Treasure Chest Game) (50)': ('Item', None, 0x0075, {}), 'Piece of Heart (Treasure Chest Game)': ('Item', True, 0x0076, {'alias': ('Piece of Heart', 1), 'progressive': float('Inf')}), 'Ice Trap': ('Item', None, 0x007C, {'junk': 0}), 'Progressive Hookshot': ('Item', True, 0x0080, {'progressive': 2}), 'Progressive Strength Upgrade': ('Item', True, 0x0081, {'progressive': 3}), - 'Bomb Bag': ('Item', True, 0x0082, None), - 'Bow': ('Item', True, 0x0083, None), - 'Slingshot': ('Item', True, 0x0084, None), + 'Bomb Bag': ('Item', True, 0x0082, {}), + 'Bow': ('Item', True, 0x0083, {}), + 'Slingshot': ('Item', True, 0x0084, {}), 'Progressive Wallet': ('Item', True, 0x0085, {'progressive': 3}), 'Progressive Scale': ('Item', True, 0x0086, {'progressive': 2}), - 'Deku Nut Capacity': ('Item', None, 0x0087, None), - 'Deku Stick Capacity': ('Item', None, 0x0088, None), - 'Bombchus': ('Item', True, 0x0089, None), - 'Magic Meter': ('Item', True, 0x008A, None), - 'Ocarina': ('Item', True, 0x008B, None), + 'Deku Nut Capacity': ('Item', None, 0x0087, {}), + 'Deku Stick Capacity': ('Item', None, 0x0088, {}), + 'Bombchus': ('Item', True, 0x0089, {}), + 'Magic Meter': ('Item', True, 0x008A, {}), + 'Ocarina': ('Item', True, 0x008B, {}), 'Bottle with Red Potion': ('Item', True, 0x008C, {'bottle': True, 'shop_object': 0x0F}), 'Bottle with Green Potion': ('Item', True, 0x008D, {'bottle': True, 'shop_object': 0x0F}), 'Bottle with Blue Potion': ('Item', True, 0x008E, {'bottle': True, 'shop_object': 0x0F}), @@ -116,32 +116,32 @@ 'Bottle with Bugs': ('Item', True, 0x0092, {'bottle': True, 'shop_object': 0x0F}), 'Bottle with Big Poe': ('Item', True, 0x0093, {'shop_object': 0x0F}), 'Bottle with Poe': ('Item', True, 0x0094, {'bottle': True, 'shop_object': 0x0F}), - 'Boss Key (Forest Temple)': ('BossKey', True, 0x0095, None), - 'Boss Key (Fire Temple)': ('BossKey', True, 0x0096, None), - 'Boss Key (Water Temple)': ('BossKey', True, 0x0097, None), - 'Boss Key (Spirit Temple)': ('BossKey', True, 0x0098, None), - 'Boss Key (Shadow Temple)': ('BossKey', True, 0x0099, None), - 'Boss Key (Ganons Castle)': ('GanonBossKey', True, 0x009A, None), - 'Compass (Deku Tree)': ('Compass', False, 0x009B, None), - 'Compass (Dodongos Cavern)': ('Compass', False, 0x009C, None), - 'Compass (Jabu Jabus Belly)': ('Compass', False, 0x009D, None), - 'Compass (Forest Temple)': ('Compass', False, 0x009E, None), - 'Compass (Fire Temple)': ('Compass', False, 0x009F, None), - 'Compass (Water Temple)': ('Compass', False, 0x00A0, None), - 'Compass (Spirit Temple)': ('Compass', False, 0x00A1, None), - 'Compass (Shadow Temple)': ('Compass', False, 0x00A2, None), - 'Compass (Bottom of the Well)': ('Compass', False, 0x00A3, None), - 'Compass (Ice Cavern)': ('Compass', False, 0x00A4, None), - 'Map (Deku Tree)': ('Map', False, 0x00A5, None), - 'Map (Dodongos Cavern)': ('Map', False, 0x00A6, None), - 'Map (Jabu Jabus Belly)': ('Map', False, 0x00A7, None), - 'Map (Forest Temple)': ('Map', False, 0x00A8, None), - 'Map (Fire Temple)': ('Map', False, 0x00A9, None), - 'Map (Water Temple)': ('Map', False, 0x00AA, None), - 'Map (Spirit Temple)': ('Map', False, 0x00AB, None), - 'Map (Shadow Temple)': ('Map', False, 0x00AC, None), - 'Map (Bottom of the Well)': ('Map', False, 0x00AD, None), - 'Map (Ice Cavern)': ('Map', False, 0x00AE, None), + 'Boss Key (Forest Temple)': ('BossKey', True, 0x0095, {}), + 'Boss Key (Fire Temple)': ('BossKey', True, 0x0096, {}), + 'Boss Key (Water Temple)': ('BossKey', True, 0x0097, {}), + 'Boss Key (Spirit Temple)': ('BossKey', True, 0x0098, {}), + 'Boss Key (Shadow Temple)': ('BossKey', True, 0x0099, {}), + 'Boss Key (Ganons Castle)': ('GanonBossKey', True, 0x009A, {}), + 'Compass (Deku Tree)': ('Compass', False, 0x009B, {}), + 'Compass (Dodongos Cavern)': ('Compass', False, 0x009C, {}), + 'Compass (Jabu Jabus Belly)': ('Compass', False, 0x009D, {}), + 'Compass (Forest Temple)': ('Compass', False, 0x009E, {}), + 'Compass (Fire Temple)': ('Compass', False, 0x009F, {}), + 'Compass (Water Temple)': ('Compass', False, 0x00A0, {}), + 'Compass (Spirit Temple)': ('Compass', False, 0x00A1, {}), + 'Compass (Shadow Temple)': ('Compass', False, 0x00A2, {}), + 'Compass (Bottom of the Well)': ('Compass', False, 0x00A3, {}), + 'Compass (Ice Cavern)': ('Compass', False, 0x00A4, {}), + 'Map (Deku Tree)': ('Map', False, 0x00A5, {}), + 'Map (Dodongos Cavern)': ('Map', False, 0x00A6, {}), + 'Map (Jabu Jabus Belly)': ('Map', False, 0x00A7, {}), + 'Map (Forest Temple)': ('Map', False, 0x00A8, {}), + 'Map (Fire Temple)': ('Map', False, 0x00A9, {}), + 'Map (Water Temple)': ('Map', False, 0x00AA, {}), + 'Map (Spirit Temple)': ('Map', False, 0x00AB, {}), + 'Map (Shadow Temple)': ('Map', False, 0x00AC, {}), + 'Map (Bottom of the Well)': ('Map', False, 0x00AD, {}), + 'Map (Ice Cavern)': ('Map', False, 0x00AE, {}), 'Small Key (Forest Temple)': ('SmallKey', True, 0x00AF, {'progressive': float('Inf')}), 'Small Key (Fire Temple)': ('SmallKey', True, 0x00B0, {'progressive': float('Inf')}), 'Small Key (Water Temple)': ('SmallKey', True, 0x00B1, {'progressive': float('Inf')}), @@ -151,14 +151,14 @@ 'Small Key (Gerudo Training Ground)': ('SmallKey', True, 0x00B5, {'progressive': float('Inf')}), 'Small Key (Thieves Hideout)': ('HideoutSmallKey', True, 0x00B6, {'progressive': float('Inf')}), 'Small Key (Ganons Castle)': ('SmallKey', True, 0x00B7, {'progressive': float('Inf')}), - 'Double Defense': ('Item', None, 0x00B8, None), + 'Double Defense': ('Item', None, 0x00B8, {}), 'Buy Magic Bean': ('Item', True, 0x0016, {'alias': ('Magic Bean', 10), 'progressive': 10}), 'Magic Bean Pack': ('Item', True, 0x00C9, {'alias': ('Magic Bean', 10), 'progressive': 10}), 'Triforce Piece': ('Item', True, 0x00CA, {'progressive': float('Inf')}), 'Zeldas Letter': ('Item', True, 0x000B, {'trade': True}), - 'Time Travel': ('Event', True, None, None), - 'Scarecrow Song': ('Event', True, None, None), - 'Triforce': ('Event', True, None, None), + 'Time Travel': ('Event', True, None, {}), + 'Scarecrow Song': ('Event', True, None, {}), + 'Triforce': ('Event', True, None, {}), 'Small Key Ring (Forest Temple)': ('SmallKey', True, 0x00CB, {'alias': ('Small Key (Forest Temple)', 10), 'progressive': float('Inf')}), 'Small Key Ring (Fire Temple)': ('SmallKey', True, 0x00CC, {'alias': ('Small Key (Fire Temple)', 10), 'progressive': float('Inf')}), @@ -225,32 +225,32 @@ # Event items otherwise generated by generic event logic # can be defined here to enforce their appearance in playthroughs. - 'Water Temple Clear': ('Event', True, None, None), - 'Forest Trial Clear': ('Event', True, None, None), - 'Fire Trial Clear': ('Event', True, None, None), - 'Water Trial Clear': ('Event', True, None, None), - 'Shadow Trial Clear': ('Event', True, None, None), - 'Spirit Trial Clear': ('Event', True, None, None), - 'Light Trial Clear': ('Event', True, None, None), - 'Epona': ('Event', True, None, None), + 'Water Temple Clear': ('Event', True, None, {}), + 'Forest Trial Clear': ('Event', True, None, {}), + 'Fire Trial Clear': ('Event', True, None, {}), + 'Water Trial Clear': ('Event', True, None, {}), + 'Shadow Trial Clear': ('Event', True, None, {}), + 'Spirit Trial Clear': ('Event', True, None, {}), + 'Light Trial Clear': ('Event', True, None, {}), + 'Epona': ('Event', True, None, {}), - 'Deku Stick Drop': ('Drop', True, None, None), - 'Deku Nut Drop': ('Drop', True, None, None), - 'Blue Fire': ('Drop', True, None, None), - 'Fairy': ('Drop', True, None, None), - 'Fish': ('Drop', True, None, None), - 'Bugs': ('Drop', True, None, None), - 'Big Poe': ('Drop', True, None, None), - 'Bombchu Drop': ('Drop', True, None, None), - 'Deku Shield Drop': ('Drop', True, None, None), + 'Deku Stick Drop': ('Drop', True, None, {}), + 'Deku Nut Drop': ('Drop', True, None, {}), + 'Blue Fire': ('Drop', True, None, {}), + 'Fairy': ('Drop', True, None, {}), + 'Fish': ('Drop', True, None, {}), + 'Bugs': ('Drop', True, None, {}), + 'Big Poe': ('Drop', True, None, {}), + 'Bombchu Drop': ('Drop', True, None, {}), + 'Deku Shield Drop': ('Drop', True, None, {}), # Consumable refills defined mostly to placate 'starting with' options - 'Arrows': ('Refill', None, None, None), - 'Bombs': ('Refill', None, None, None), - 'Deku Seeds': ('Refill', None, None, None), - 'Deku Sticks': ('Refill', None, None, None), - 'Deku Nuts': ('Refill', None, None, None), - 'Rupees': ('Refill', None, None, None), + 'Arrows': ('Refill', None, None, {}), + 'Bombs': ('Refill', None, None, {}), + 'Deku Seeds': ('Refill', None, None, {}), + 'Deku Sticks': ('Refill', None, None, {}), + 'Deku Nuts': ('Refill', None, None, {}), + 'Rupees': ('Refill', None, None, {}), 'Minuet of Forest': ('Song', True, 0x00BB, { diff --git a/ItemPool.py b/ItemPool.py index f07966285..75fa44226 100644 --- a/ItemPool.py +++ b/ItemPool.py @@ -412,7 +412,7 @@ def generate_itempool(world: World) -> None: # set up item pool (pool, placed_items) = get_pool_core(world) - placed_items_count = {} + placed_items_count: dict[str, int] = {} world.itempool = ItemFactory(pool, world) world.initialize_items(world.itempool + list(placed_items.values())) placed_locations = list(filter(lambda loc: loc.name in placed_items, world.get_locations())) @@ -707,7 +707,7 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]: location.disabled = DisableType.DISABLED # Freestanding Rupees and Hearts - elif location.type in ('ActorOverride', 'Freestanding', 'RupeeTower'): + elif location.type in ('Freestanding', 'RupeeTower'): if world.settings.shuffle_freestanding_items == 'all': shuffle_item = True elif world.settings.shuffle_freestanding_items == 'dungeons' and location.dungeon is not None: diff --git a/JSONDump.py b/JSONDump.py index 0c04c5f04..49b3bf56a 100644 --- a/JSONDump.py +++ b/JSONDump.py @@ -75,7 +75,7 @@ def get_keys(obj: AlignedDict, depth: int): yield from get_keys(value, depth - 1) -def dump_dict(obj: dict, current_indent: str = '', sub_width: Optional[Sequence[int, int]] = None, ensure_ascii: bool = False) -> str: +def dump_dict(obj: dict, current_indent: str = '', sub_width: Optional[tuple[int, int]] = None, ensure_ascii: bool = False) -> str: entries = [] key_width = None @@ -122,7 +122,7 @@ def dump_dict(obj: dict, current_indent: str = '', sub_width: Optional[Sequence[ return output -def dump_obj(obj, current_indent: str = '', sub_width: Optional[Sequence[int, int]] = None, ensure_ascii: bool = False) -> str: +def dump_obj(obj, current_indent: str = '', sub_width: Optional[tuple[int, int]] = None, ensure_ascii: bool = False) -> str: if is_list(obj): return dump_list(obj, current_indent, ensure_ascii) elif is_dict(obj): diff --git a/Location.py b/Location.py index 3cabfdb32..db1532e6d 100644 --- a/Location.py +++ b/Location.py @@ -175,7 +175,7 @@ def LocationFactory(locations: str | list[str]) -> Location | list[Location]: match_location = location else: match_location = next(filter(lambda k: k.lower() == location.lower(), location_table), None) - if match_location: + if match_location is not None: type, scene, default, addresses, vanilla_item, filter_tags = location_table[match_location] if addresses is None: addresses = (None, None) diff --git a/LocationList.py b/LocationList.py index 6f987ea46..c0f6559eb 100644 --- a/LocationList.py +++ b/LocationList.py @@ -54,8 +54,6 @@ def shop_address(shop_id: int, shelf_id: int) -> int: # Actor ID - The position of the actor in the actor table. # The default variable can also be a list of such tuples in the case that multiple scene setups contain the same locations to be shuffled together. -# Note: for ActorOverride locations, the "Addresses" variable is in the form ([addresses], [bytes]) where addresses is a list of memory locations in ROM to be updated, and bytes is the data that will be written to that location - # Location: Type Scene Default Addresses Vanilla Item Categories location_table: dict[str, tuple[str, Optional[int], LocationDefault, LocationAddresses, Optional[str], LocationFilterTags]] = OrderedDict([ ## Dungeon Rewards @@ -2505,11 +2503,10 @@ def shop_address(shop_id: int, shelf_id: int) -> int: 'Chest': [name for (name, data) in location_table.items() if data[0] == 'Chest'], 'Collectable': [name for (name, data) in location_table.items() if data[0] == 'Collectable'], 'Boss': [name for (name, data) in location_table.items() if data[0] == 'Boss'], - 'ActorOverride': [name for (name, data) in location_table.items() if data[0] == 'ActorOverride'], 'BossHeart': [name for (name, data) in location_table.items() if data[0] == 'BossHeart'], 'CollectableLike': [name for (name, data) in location_table.items() if data[0] in ('Collectable', 'BossHeart', 'GS Token', 'SilverRupee')], 'CanSee': [name for (name, data) in location_table.items() - if data[0] in ('Collectable', 'BossHeart', 'GS Token', 'Shop', 'MaskShop', 'Freestanding', 'ActorOverride', 'RupeeTower', 'Pot', 'Crate', 'FlyingPot', 'SmallCrate', 'Beehive', 'SilverRupee') + if data[0] in ('Collectable', 'BossHeart', 'GS Token', 'Shop', 'MaskShop', 'Freestanding', 'RupeeTower', 'Pot', 'Crate', 'FlyingPot', 'SmallCrate', 'Beehive', 'SilverRupee') # Treasure Box Shop, Bombchu Bowling, Hyrule Field (OoT), Lake Hylia (RL/FA) or data[0:2] in [('Chest', 0x10), ('NPC', 0x4B), ('NPC', 0x51), ('NPC', 0x57)]], 'Dungeon': [name for (name, data) in location_table.items() if data[5] is not None and any(dungeon in data[5] for dungeon in dungeons)], diff --git a/MQ.py b/MQ.py index 7bc8d8d0e..95b3b7f37 100644 --- a/MQ.py +++ b/MQ.py @@ -46,7 +46,7 @@ from __future__ import annotations import json from struct import pack, unpack -from typing import Optional, Any +from typing import Optional, Any, TypedDict from Rom import Rom from Utils import data_path @@ -54,6 +54,13 @@ SCENE_TABLE: int = 0xB71440 +class JsonFile(TypedDict): + Name: str + Start: Optional[str] + End: Optional[str] + RemapStart: Optional[str] + + class File: def __init__(self, name: str, start: int = 0, end: Optional[int] = None, remap: Optional[int] = None) -> None: self.name: str = name @@ -66,7 +73,7 @@ def __init__(self, name: str, start: int = 0, end: Optional[int] = None, remap: self.dma_key: int = self.start @classmethod - def from_json(cls, file: dict[str, Optional[str]]) -> File: + def from_json(cls, file: JsonFile) -> File: return cls( file['Name'], int(file['Start'], 16) if file.get('Start', None) is not None else 0, diff --git a/Messages.py b/Messages.py index 5e2158e40..139d535f9 100644 --- a/Messages.py +++ b/Messages.py @@ -127,7 +127,7 @@ GS_TOKEN_MESSAGES: list[int] = [0x00B4, 0x00B5] # Get Gold Skulltula Token messages ERROR_MESSAGE: int = 0x0001 -new_messages = [] # Used to keep track of new/updated messages to prevent duplicates. Clear it at the start of patches +new_messages: list[int] = [] # Used to keep track of new/updated messages to prevent duplicates. Clear it at the start of patches # messages for shorter item messages # ids are in the space freed up by move_shop_item_messages() diff --git a/Models.py b/Models.py index 4ac6f6486..fb149c6fa 100644 --- a/Models.py +++ b/Models.py @@ -94,10 +94,12 @@ def WriteModelDataLo(self, data: int) -> None: # Either return the starting index of the requested data (when start == 0) # or the offset of the element in the footer, if it exists (start > 0) def scan(bytes: bytearray, data: bytearray | str, start: int = 0) -> int: - databytes = data + databytes: bytearray | bytes # If a string was passed, encode string as bytes if isinstance(data, str): databytes = data.encode() + else: + databytes = data dataindex = 0 for i in range(start, len(bytes)): # Byte matches next byte in string diff --git a/Music.py b/Music.py index 65a564f87..eab9e9646 100644 --- a/Music.py +++ b/Music.py @@ -362,7 +362,7 @@ def rebuild_sequences(rom: Rom, sequences: list[Sequence], log: CosmeticsLog, sy replacement_dict = {seq.replaces: seq for seq in sequences} # List of sequences (actual sequence data objects) containing the vanilla sequence data - old_sequences = [] + old_sequences: list[SequenceData] = [] bgmlist = [sequence_id for title, sequence_id in bgm_sequence_ids] fanfarelist = [sequence_id for title, sequence_id in fanfare_sequence_ids] ocarinalist = [sequence_id for title, sequence_id in ocarina_sequence_ids] diff --git a/N64Patch.py b/N64Patch.py index 9c981370f..41622ca7d 100644 --- a/N64Patch.py +++ b/N64Patch.py @@ -145,7 +145,7 @@ def create_patch_file(rom: Rom, file: str, xor_range: tuple[int, int] = (0x00B8A # Write the address changes. We'll store the data with XOR so that # the patch data won't be raw data from the patched rom. - data = [] + data: list[int] = [] block_start = block_end = None BLOCK_HEADER_SIZE = 7 # this is used to break up gaps for address in changed_addresses: diff --git a/OcarinaSongs.py b/OcarinaSongs.py index 4b616b89b..c8b6c0d5f 100644 --- a/OcarinaSongs.py +++ b/OcarinaSongs.py @@ -3,7 +3,7 @@ import sys from collections.abc import Callable, Sequence from itertools import chain -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, TypeVar from Fill import ShuffleError @@ -18,7 +18,6 @@ ActivationTransform: TypeAlias = "Callable[[list[int]], list[int]]" PlaybackTransform: TypeAlias = "Callable[[list[dict[str, int]]], list[dict[str, int]]]" -Transform: TypeAlias = "ActivationTransform | PlaybackTransform" PLAYBACK_START: int = 0xB781DC PLAYBACK_LENGTH: int = 0xA0 @@ -163,7 +162,8 @@ def transpose(piece: list[int]) -> list[int]: return transpose -def compose(f: Transform, g: Transform) -> Transform: +T = TypeVar('T', ActivationTransform, PlaybackTransform) +def compose(f: T, g: T) -> T: return lambda x: f(g(x)) diff --git a/Patches.py b/Patches.py index 6a5bf35a2..bcfbb3bb4 100644 --- a/Patches.py +++ b/Patches.py @@ -56,8 +56,8 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> Rom: ] for (bin_path, write_address) in bin_patches: - with open(bin_path, 'rb') as stream: - bytes_compressed = stream.read() + with open(bin_path, 'rb') as bin_stream: + bytes_compressed = bin_stream.read() bytes_diff = zlib.decompress(bytes_compressed) original_bytes = rom.original.buffer[write_address: write_address + len(bytes_diff)] new_bytes = bytearray([a ^ b for a, b in zip(bytes_diff, original_bytes)]) @@ -1705,7 +1705,6 @@ def calculate_traded_flags(world): # This ensures that if the region behind the boss door is a boss arena, the medallion or stone will be used. priority_types = ( "Freestanding", - "ActorOverride", "RupeeTower", "Pot", "Crate", @@ -1831,11 +1830,8 @@ def calculate_traded_flags(world): # Patch freestanding items if world.settings.shuffle_freestanding_items: # Get freestanding item locations - actor_override_locations = [location for location in world.get_locations() if location.disabled == DisableType.ENABLED and location.type == 'ActorOverride'] rupeetower_locations = [location for location in world.get_locations() if location.disabled == DisableType.ENABLED and location.type == 'RupeeTower'] - for location in actor_override_locations: - patch_actor_override(location, rom) for location in rupeetower_locations: patch_rupee_tower(location, rom) @@ -2692,7 +2688,7 @@ def get_override_entry(location: Location) -> Optional[OverrideEntry]: return None # Don't add freestanding items, pots/crates, beehives to the override table if they're disabled. We use this check to determine how to draw and interact with them - if location.type in ["ActorOverride", "Freestanding", "RupeeTower", "Pot", "Crate", "FlyingPot", "SmallCrate", "Beehive", "Wonderitem"] and location.disabled != DisableType.ENABLED: + if location.type in ["Freestanding", "RupeeTower", "Pot", "Crate", "FlyingPot", "SmallCrate", "Beehive", "Wonderitem"] and location.disabled != DisableType.ENABLED: return None player_id = location.item.world.id + 1 @@ -2714,7 +2710,7 @@ def get_override_entry(location: Location) -> Optional[OverrideEntry]: default = location.default[0] room, scene_setup, flag = default default = (room << 8) + (scene_setup << 14) + flag - elif location.type in ('Collectable', 'ActorOverride'): + elif location.type == 'Collectable': type = 2 elif location.type == 'GS Token': type = 3 @@ -3142,10 +3138,10 @@ def configure_dungeon_info(rom: Rom, world: World) -> None: dungeon_rewards = [0xff] * 14 dungeon_reward_areas = bytearray() for reward in ('Kokiri Emerald', 'Goron Ruby', 'Zora Sapphire', 'Light Medallion', 'Forest Medallion', 'Fire Medallion', 'Water Medallion', 'Shadow Medallion', 'Spirit Medallion'): - location = next(filter(lambda loc: loc.item.name == reward, world.get_filled_locations())) + location = next(world.get_filled_locations(lambda item: item.name == reward)) area = HintArea.at(location) dungeon_reward_areas += area.short_name.encode('ascii').ljust(0x16) + b'\0' - if area.is_dungeon: + if area.dungeon_name is not None: dungeon_rewards[codes.index(area.dungeon_name)] = boss_reward_index(location.item) dungeon_is_mq = [1 if world.dungeon_mq.get(c) else 0 for c in codes] @@ -3162,15 +3158,6 @@ def configure_dungeon_info(rom: Rom, world: World) -> None: rom.write_bytes(rom.sym('CFG_DUNGEON_REWARD_AREAS'), dungeon_reward_areas) -# Overwrite an actor in rom w/ the actor data from LocationList -def patch_actor_override(location: Location, rom: Rom) -> None: - addresses = location.address - patch = location.address2 - if addresses is not None and patch is not None: - for address in addresses: - rom.write_bytes(address, patch) - - # Patch rupee towers (circular patterns of rupees) to include their flag in their actor initialization data z rotation. # Also used for goron pot, shadow spinning pots def patch_rupee_tower(location: Location, rom: Rom) -> None: @@ -3183,6 +3170,7 @@ def patch_rupee_tower(location: Location, rom: Rom) -> None: flag = flag + (room << 8) if location.address: + assert isinstance(location.address, list) for address in location.address: rom.write_bytes(address + 12, flag.to_bytes(2, byteorder='big')) diff --git a/Plandomizer.py b/Plandomizer.py index 6aaa0a5ce..ccd962da4 100644 --- a/Plandomizer.py +++ b/Plandomizer.py @@ -67,7 +67,7 @@ def update(self, src_dict: dict[str, Any], update_all: bool = False) -> None: if update_all or k in src_dict: setattr(self, k, src_dict.get(k, p)) - def to_json(self) -> dict[str, Any]: + def to_json(self) -> json: return {k: getattr(self, k) for (k, d) in self.properties.items() if getattr(self, k) != d} def __str__(self) -> str: @@ -1092,7 +1092,7 @@ def configure_effective_starting_items(self, worlds: list[World], world: World) for location_name in skipped_locations_from_dungeons: location = world.get_location(location_name) hint_area = HintArea.at(location) - if hint_area.is_dungeon and world.empty_dungeons[hint_area.dungeon_name].empty: + if hint_area.dungeon_name is not None and world.empty_dungeons[hint_area.dungeon_name].empty: skipped_locations.append(location.name) world.item_added_hint_types['barren'].append(location.item.name) for iter_world in worlds: diff --git a/Region.py b/Region.py index 13fb4fd75..f176138de 100644 --- a/Region.py +++ b/Region.py @@ -81,6 +81,7 @@ def hint(self) -> Optional[HintArea]: return HintArea[self.hint_name] if self.dungeon: return self.dungeon.hint + return None @property def alt_hint(self) -> Optional[HintArea]: @@ -88,6 +89,7 @@ def alt_hint(self) -> Optional[HintArea]: if self.alt_hint_name is not None: return HintArea[self.alt_hint_name] + return None def can_fill(self, item: Item, manual: bool = False) -> bool: from Hints import HintArea diff --git a/Rom.py b/Rom.py index 0ba3419e8..20402266c 100644 --- a/Rom.py +++ b/Rom.py @@ -129,7 +129,7 @@ def decompress_rom(self, input_file: str, output_file: str, verify_crc: bool = T subprocess.call(subcall, **subprocess_args()) self.read_rom(output_file, verify_crc=verify_crc) - def write_byte(self, address: int, value: int) -> None: + def write_byte(self, address: int | None, value: int) -> None: super().write_byte(address, value) self.changed_address[self.last_address-1] = value diff --git a/RuleParser.py b/RuleParser.py index 93f132219..d948982d2 100644 --- a/RuleParser.py +++ b/RuleParser.py @@ -138,12 +138,12 @@ def visit_Tuple(self, node: ast.Tuple) -> Any: item, count = node.elts if not isinstance(item, ast.Name) and not (isinstance(item, ast.Constant) and isinstance(item.value, str)): - raise Exception('Parse Error: first value must be an item. Got %s' % item.__class__.__name__, self.current_spot.name, ast.dump(node, False)) + raise Exception('Parse Error: first value must be an item. Got %s' % item.__class__.__name__, None if self.current_spot is None else self.current_spot.name, ast.dump(node, False)) if isinstance(item, ast.Constant) and isinstance(item.value, str): item = ast.Name(id=escape_name(item.value), ctx=ast.Load()) if not (isinstance(count, ast.Name) or (isinstance(count, ast.Constant) and isinstance(count.value, int))): - raise Exception('Parse Error: second value must be a number. Got %s' % item.__class__.__name__, self.current_spot.name, ast.dump(node, False)) + raise Exception('Parse Error: second value must be a number. Got %s' % item.__class__.__name__, None if self.current_spot is None else self.current_spot.name, ast.dump(node, False)) if isinstance(count, ast.Name): # Must be a settings constant diff --git a/Rules.py b/Rules.py index 0f7a59740..feb1f1f76 100644 --- a/Rules.py +++ b/Rules.py @@ -25,6 +25,7 @@ def set_rules(world: World) -> None: is_child = world.parser.parse_rule('is_child') for location in world.get_locations(): + assert location.world is not None if world.settings.shuffle_song_items == 'song': if location.type == 'Song': # allow junk items, but songs must still have matching world diff --git a/SaveContext.py b/SaveContext.py index ca0c5b59f..ac8e3a735 100644 --- a/SaveContext.py +++ b/SaveContext.py @@ -56,12 +56,12 @@ class Address: prev_address: int = 0 EXTENDED_CONTEXT_START = 0x1450 - def __init__(self, address: Optional[int] = None, extended: bool = False, size: int = 4, mask: int = 0xFFFFFFFF, max: Optional[int] = None, - choices: Optional[dict[str, int]] = None, value: Optional[str] = None) -> None: + def __init__(self, address: Optional[int] = None, *, extended: bool = False, size: int = 4, mask: int = 0xFFFFFFFF, max: Optional[int] = None, + choices: Optional[dict[str, int]] = None) -> None: self.address: int = Address.prev_address if address is None else address if extended and address is not None: self.address += Address.EXTENDED_CONTEXT_START - self.value: Optional[str | int] = value + self.value: Optional[int] = None self.size: int = size self.choices: Optional[dict[str, int]] = choices self.mask: int = mask diff --git a/SceneFlags.py b/SceneFlags.py index 7f7527b25..98e92386c 100644 --- a/SceneFlags.py +++ b/SceneFlags.py @@ -16,7 +16,7 @@ # where room_setup_number defines the room + scene setup as ((setup << 6) + room) for scene n # and max_flags is the highest used enemy flag for that setup/room def get_collectible_flag_table(world: World) -> tuple[dict[int, dict[int, int]], list[tuple[Location, tuple[int, int, int], tuple[int, int, int]]]]: - scene_flags = {} + scene_flags: dict[int, dict[int, int]] = {} alt_list = [] for i in range(0, 101): scene_flags[i] = {} diff --git a/Search.py b/Search.py index 88519d041..1c3eb0263 100644 --- a/Search.py +++ b/Search.py @@ -127,7 +127,7 @@ def reset(self) -> None: # Returns a queue of the exits whose access rule failed, # as a cache for the exits to try on the next iteration. def _expand_regions(self, exit_queue: list[Entrance], regions: dict[Region, int], age: Optional[str]) -> list[Entrance]: - failed = [] + failed: list[Entrance] = [] for exit in exit_queue: if exit.world and exit.connected_region and exit.connected_region not in regions: # Evaluate the access rule directly, without tod diff --git a/SettingTypes.py b/SettingTypes.py index 03bc27973..1577e482e 100644 --- a/SettingTypes.py +++ b/SettingTypes.py @@ -22,12 +22,14 @@ def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Option # dictionary of options to their text names choices = {} if choices is None else choices + self.choices: dict + self.choice_list: list if isinstance(choices, list): - self.choices: dict = {k: k for k in choices} - self.choice_list: list = list(choices) + self.choices = {k: k for k in choices} + self.choice_list = list(choices) else: - self.choices: dict = dict(choices) - self.choice_list: list = list(choices.keys()) + self.choices = dict(choices) + self.choice_list = list(choices.keys()) self.reverse_choices: dict = {v: k for k, v in self.choices.items()} # number of bits needed to store the setting, used in converting settings to a string diff --git a/Settings.py b/Settings.py index 17fa9701d..be2c8386d 100644 --- a/Settings.py +++ b/Settings.py @@ -29,8 +29,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): def _get_help_string(self, action) -> Optional[str]: - if action.help is not None: + if action.help is not None: return textwrap.dedent(action.help) + return None # 32 characters diff --git a/SettingsList.py b/SettingsList.py index d762a4f37..7f6be7ede 100644 --- a/SettingsList.py +++ b/SettingsList.py @@ -5093,7 +5093,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[list[str] | dict[str, list[Entrance]]] = None) -> str: - source = [] + source: Iterable[str] = [] if value_type == 'item': source = ItemInfo.items.keys() elif value_type == 'location': diff --git a/SettingsListTricks.py b/SettingsListTricks.py index ed536ba44..93a83a4c1 100644 --- a/SettingsListTricks.py +++ b/SettingsListTricks.py @@ -1,11 +1,17 @@ from __future__ import annotations +from typing import TypedDict +class TrickInfo(TypedDict): + name: str + tags: tuple[str, ...] + tooltip: str + # 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[str, dict[str, str | tuple[str, ...]]] = { +logic_tricks: dict[str, TrickInfo] = { # General tricks diff --git a/SettingsToJson.py b/SettingsToJson.py index 0dddae3ef..1c1d6d940 100755 --- a/SettingsToJson.py +++ b/SettingsToJson.py @@ -69,7 +69,7 @@ def get_setting_json(setting: str, web_version: bool, as_array: bool = False) -> 'options': [], 'default': setting_info.default, 'text': setting_info.gui_text, - 'tooltip': remove_trailing_lines('
'.join(line.strip() for line in setting_info.gui_tooltip.split('\n'))), + 'tooltip': remove_trailing_lines('
'.join(line.strip() for line in (setting_info.gui_tooltip or '').split('\n'))), 'type': setting_info.gui_type, 'shared': setting_info.shared, } diff --git a/Spoiler.py b/Spoiler.py index 90764b00d..acae38919 100644 --- a/Spoiler.py +++ b/Spoiler.py @@ -72,7 +72,7 @@ def __init__(self, worlds: list[World]) -> None: self.file_hash: list[int] = [] def build_file_hash(self) -> None: - dist_file_hash = self.settings.distribution.file_hash + dist_file_hash = self.settings.distribution.file_hash or [None, None, None, None, None] for i in range(5): self.file_hash.append(random.randint(0, 31) if dist_file_hash[i] is None else HASH_ICONS.index(dist_file_hash[i])) diff --git a/State.py b/State.py index 1b0534d6d..4a78f0bd7 100644 --- a/State.py +++ b/State.py @@ -106,10 +106,10 @@ def has_ocarina_buttons(self, count: int) -> bool: return (self.count_of(ItemInfo.ocarina_buttons_ids)) >= count # TODO: Store the item's solver id in the goal - def has_item_goal(self, item_goal: dict[str, Any]) -> bool: + def has_item_goal(self, item_goal: GoalItem) -> bool: return self.solv_items[ItemInfo.solver_ids[escape_name(item_goal['name'])]] >= item_goal['minimum'] - def has_full_item_goal(self, category: GoalCategory, goal: Goal, item_goal: dict[str, Any]) -> bool: + def has_full_item_goal(self, category: GoalCategory, goal: Goal, item_goal: GoalItem) -> bool: local_goal = self.world.goal_categories[category.name].get_goal(goal.name) per_world_max_quantity = local_goal.get_item(item_goal['name'])['quantity'] return self.solv_items[ItemInfo.solver_ids[escape_name(item_goal['name'])]] >= per_world_max_quantity diff --git a/TextBox.py b/TextBox.py index 6eb4ad515..693a36824 100644 --- a/TextBox.py +++ b/TextBox.py @@ -112,7 +112,7 @@ def replace_bytes(match: re.Match) -> str: box_codes = [] # Arrange our words into lines. - lines = [] + lines: list[list[list[TextCode]]] = [] start_index = 0 end_index = 0 box_count = 1 diff --git a/Unittest.py b/Unittest.py index 764295a15..53edb37da 100644 --- a/Unittest.py +++ b/Unittest.py @@ -133,7 +133,7 @@ def get_actual_pool(spoiler: dict[str, Any]) -> dict[str, int]: key: Item name value: count in spoiler """ - actual_pool = {} + actual_pool: dict[str, int] = {} for location, item in spoiler['locations'].items(): if isinstance(item, dict): test_item = item['item'] diff --git a/Utils.py b/Utils.py index 09c61c47b..cad07e4f6 100644 --- a/Utils.py +++ b/Utils.py @@ -18,36 +18,36 @@ def is_bundled() -> bool: return getattr(sys, 'frozen', False) +CACHED_LOCAL_PATH: Optional[str] = None def local_path(path: str = '') -> str: - if not hasattr(local_path, "cached_path"): - local_path.cached_path = None + global CACHED_LOCAL_PATH - if local_path.cached_path is not None: - return os.path.join(local_path.cached_path, path) + if CACHED_LOCAL_PATH is not None: + return os.path.join(CACHED_LOCAL_PATH, path) if is_bundled(): # we are running in a bundle - local_path.cached_path = os.path.dirname(os.path.realpath(sys.executable)) + CACHED_LOCAL_PATH = os.path.dirname(os.path.realpath(sys.executable)) else: # we are running in a normal Python environment - local_path.cached_path = os.path.dirname(os.path.realpath(__file__)) + CACHED_LOCAL_PATH = os.path.dirname(os.path.realpath(__file__)) - return os.path.join(local_path.cached_path, path) + return os.path.join(CACHED_LOCAL_PATH, path) +CACHED_DATA_PATH: Optional[str] = None def data_path(path: str = '') -> str: - if not hasattr(data_path, "cached_path"): - data_path.cached_path = None + global CACHED_DATA_PATH - if data_path.cached_path is not None: - return os.path.join(data_path.cached_path, path) + if CACHED_DATA_PATH is not None: + return os.path.join(CACHED_DATA_PATH, path) # Even if it's bundled we use __file__ # if it's not bundled, then we want to use the source.py dir + Data # if it's bundled, then we want to use the extraction dir + Data - data_path.cached_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") + CACHED_DATA_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") - return os.path.join(data_path.cached_path, path) + return os.path.join(CACHED_DATA_PATH, path) def default_output_path(path: str) -> str: diff --git a/World.py b/World.py index 0e86ae4b5..d92d7c599 100644 --- a/World.py +++ b/World.py @@ -12,12 +12,12 @@ from Entrance import Entrance from Goals import Goal, GoalCategory from HintList import get_required_hints, misc_item_hint_table, misc_location_hint_table -from Hints import HintArea, hint_dist_keys, hint_dist_files +from Hints import HintArea, RegionRestriction, hint_dist_files, hint_dist_keys from Item import Item, ItemFactory, ItemInfo, make_event_item from Location import Location, LocationFactory from LocationList import business_scrubs, location_groups -from OcarinaSongs import generate_song_list, Song -from Plandomizer import WorldDistribution, InvalidFileException +from OcarinaSongs import Song, generate_song_list +from Plandomizer import InvalidFileException, WorldDistribution from Region import Region, TimeOfDay from RuleParser import Rule_AST_Transformer from Settings import Settings @@ -48,6 +48,7 @@ def __init__(self, world_id: int, settings: Settings, resolve_randomized_setting self.empty_areas: dict[HintArea, dict[str, Any]] = {} self.barren_dungeon: int = 0 self.woth_dungeon: int = 0 + self.get_barren_hint_prev: RegionRestriction = RegionRestriction.NONE self.randomized_list: list[str] = [] self.parser: Rule_AST_Transformer = Rule_AST_Transformer(self) @@ -133,7 +134,7 @@ def __init__(self): self['Shadow Temple'] = self.EmptyDungeonInfo('Bongo Bongo') for area in HintArea: - if area.is_dungeon and area.dungeon_name in self: + if area.dungeon_name is not None and area.dungeon_name in self: self[area.dungeon_name].hint_name = area def __missing__(self, dungeon_name: str) -> EmptyDungeonInfo: @@ -309,7 +310,7 @@ def __missing__(self, dungeon_name: str) -> EmptyDungeonInfo: else: cat = GoalCategory(category['category'], category['priority'], minimum_goals=category['minimum_goals']) for goal in category['goals']: - cat.add_goal(Goal(self, goal['name'], goal['hint_text'], goal['color'], items=list({'name': i['name'], 'quantity': i['quantity'], 'minimum': i['minimum'], 'hintable': i['hintable']} for i in goal['items']))) + cat.add_goal(Goal(self, goal['name'], goal['hint_text'], goal['color'], items=[{'name': i['name'], 'quantity': i['quantity'], 'minimum': i['minimum'], 'hintable': i['hintable']} for i in goal['items']])) if 'count_override' in category: cat.goal_count = category['count_override'] else: @@ -1140,8 +1141,8 @@ def get_locations(self) -> list[Location]: def get_unfilled_locations(self) -> Iterable[Location]: return filter(Location.has_no_item, self.get_locations()) - def get_filled_locations(self) -> Iterable[Location]: - return filter(Location.has_item, self.get_locations()) + def get_filled_locations(self, item_filter: Callable[[Item], bool] = lambda item: True) -> Iterable[Location]: + return filter(lambda loc: loc.item is not None and item_filter(loc.item), self.get_locations()) def get_progression_locations(self) -> Iterable[Location]: return filter(Location.has_progression_item, self.get_locations()) diff --git a/texture_util.py b/texture_util.py index 9f7dcc2fb..6618293cf 100755 --- a/texture_util.py +++ b/texture_util.py @@ -58,9 +58,9 @@ def get_colors_from_rgba16(rgba16_texture: list[int]) -> list[int]: # rgba16_texture - Original texture # rgba16_patch - Patch texture. If this parameter is not supplied, this function will simply return the original texture. # returns - new texture = texture xor patch -def apply_rgba16_patch(rgba16_texture: list[int], rgba16_patch: list[int]) -> list[int]: - if rgba16_patch is not None and (len(rgba16_texture) != len(rgba16_patch)): - raise(Exception("OG Texture and Patch not the same length!")) +def apply_rgba16_patch(rgba16_texture: list[int], rgba16_patch: Optional[list[int]]) -> list[int]: + if rgba16_patch is not None and len(rgba16_texture) != len(rgba16_patch): + raise Exception("OG Texture and Patch not the same length!") new_texture = [] if not rgba16_patch: