diff --git a/Audiobank.py b/Audiobank.py
index 0f2e441fc..c4fcefd7b 100644
--- a/Audiobank.py
+++ b/Audiobank.py
@@ -4,7 +4,7 @@
# Container for storing Audiotable, Audiobank, Audiotable_index, Audiobank_index
class Audiobin:
- def __init__(self, _Audiobank: bytearray, _Audiobank_index: bytearray, _Audiotable: bytearray, _Audiotable_index: bytearray):
+ def __init__(self, _Audiobank: bytearray, _Audiobank_index: bytearray, _Audiotable: bytearray, _Audiotable_index: bytearray) -> None:
self.Audiobank: bytearray = _Audiobank
self.Audiobank_index: bytearray = _Audiobank_index
self.Audiotable: bytearray = _Audiotable
@@ -39,7 +39,7 @@ def find_sample_in_audiobanks(self, sample_data: bytearray):
return None
class Sample:
- def __init__(self, bankdata: bytearray, audiotable_file: bytearray, audiotable_index: bytearray, sample_offset: int, audiotable_id: int, parent):
+ def __init__(self, bankdata: bytearray, audiotable_file: bytearray, audiotable_index: bytearray, sample_offset: int, audiotable_id: int, parent) -> None:
# Process the sample
self.parent = parent
self.bank_offset = sample_offset
diff --git a/CI.py b/CI.py
index f5f3930e4..731581da9 100644
--- a/CI.py
+++ b/CI.py
@@ -4,32 +4,39 @@
from __future__ import annotations
import argparse
+from io import StringIO
import json
import os.path
import pathlib
import sys
+from typing import Any, NoReturn
import unittest
-from io import StringIO
-from typing import NoReturn
+
from Main import resolve_settings
+from Messages import ITEM_MESSAGES, KEYSANITY_MESSAGES, MISC_MESSAGES
from Patches import get_override_table, get_override_table_bytes
from Rom import Rom
-import Unittest as Tests
-from Messages import ITEM_MESSAGES, KEYSANITY_MESSAGES, MISC_MESSAGES
from SettingsList import SettingInfos, logic_tricks, validate_settings
import Unittest as Tests
from Utils import data_path
+ERROR_COUNT: int = 0
+ANY_FIXABLE_ERRORS: bool = False
+ANY_UNFIXABLE_ERRORS: bool = False
+
+
def error(msg: str, can_fix: bool) -> None:
- if not hasattr(error, "count"):
- error.count = 0
+ global ERROR_COUNT
+ global ANY_FIXABLE_ERRORS
+ 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
else:
- error.cannot_fix = True
+ ANY_UNFIXABLE_ERRORS = True
def run_unit_tests() -> None:
@@ -133,7 +140,7 @@ def check_preset_spoilers(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:
@@ -264,22 +271,22 @@ def run_ci_checks() -> NoReturn:
exit_ci(args.fix)
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 d32668cb0..f1f1b9ff0 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:
@@ -1273,6 +1276,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
@@ -1282,11 +1291,17 @@ 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] = []
+ self.speedup_music_for_last_triforce_piece: bool = False
+ self.slowdown_music_when_lowhp: bool = False
+ self.correct_model_colors: bool = True
+ self.uninvert_y_axis_in_first_person_camera: bool = False
+ self.display_dpad: str = 'right'
+ self.display_custom_song_names: str = 'off'
+
if self.settings.enable_cosmetic_file:
if self.settings.cosmetic_file:
try:
@@ -1309,9 +1324,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/Dungeon.py b/Dungeon.py
index e7ba3123f..3082caaff 100644
--- a/Dungeon.py
+++ b/Dungeon.py
@@ -41,10 +41,12 @@ def copy(self) -> Dungeon:
return new_dungeon
@staticmethod
- def from_vanilla_reward(item: Item) -> Dungeon:
+ def from_vanilla_reward(item: Item) -> Optional[Dungeon]:
+ assert item.world is not None
dungeons = [dungeon for dungeon in item.world.dungeons if dungeon.vanilla_reward == item.name]
if dungeons:
return dungeons[0]
+ return None
@property
def shuffle_mapcompass(self) -> str:
@@ -96,6 +98,7 @@ def vanilla_boss_name(self) -> Optional[str]:
def vanilla_reward(self) -> Optional[str]:
if self.vanilla_boss_name is not None:
return self.world.get_location(self.vanilla_boss_name).vanilla_item
+ return None
def item_name(self, text: str) -> str:
return f"{text} ({self.name})"
diff --git a/Entrance.py b/Entrance.py
index e2a765c27..c3f981f7d 100644
--- a/Entrance.py
+++ b/Entrance.py
@@ -1,11 +1,22 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional, Any
+from typing import TYPE_CHECKING, Optional, Any, TypedDict
if TYPE_CHECKING:
from Region import Region
from RulesCommon import AccessRule
from World import World
+ class EntranceData(TypedDict, total=False):
+ index: int
+ child_index: int
+ addresses: list[int]
+ savewarp_addresses: list[int]
+ grotto_id: int
+ entrance: int
+ content: int
+ scene: int
+ savewarp_fallback: int
+
class Entrance:
def __init__(self, name: str = '', parent: Optional[Region] = None) -> None:
@@ -20,7 +31,7 @@ def __init__(self, name: str = '', parent: Optional[Region] = None) -> None:
self.assumed: Optional[Entrance] = None
self.type: Optional[str] = None
self.shuffled: bool = False
- self.data: Optional[dict[str, Any]] = None
+ self.data: EntranceData = {}
self.primary: bool = False
self.always: bool = False
self.never: bool = False
diff --git a/EntranceShuffle.py b/EntranceShuffle.py
index d56900e16..7525f69bc 100644
--- a/EntranceShuffle.py
+++ b/EntranceShuffle.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import random
import logging
-from collections import OrderedDict
from collections.abc import Iterable, Container
from itertools import chain
from typing import TYPE_CHECKING, Optional
@@ -16,28 +15,31 @@
from HintList import misc_item_hint_table
if TYPE_CHECKING:
- from Entrance import Entrance
+ from Entrance import Entrance, EntranceData
from Location import Location
from Item import Item
from World import World
def set_all_entrances_data(world: World) -> None:
- for type, forward_entry, *return_entry in entrance_shuffle_table:
+ for type, forward_entry, return_entry in two_way_entrances:
forward_entrance = world.get_entrance(forward_entry[0])
forward_entrance.data = forward_entry[1]
forward_entrance.type = type
forward_entrance.primary = True
+ return_entrance = world.get_entrance(return_entry[0])
+ return_entrance.data = return_entry[1]
+ return_entrance.type = type
+ forward_entrance.bind_two_way(return_entrance)
if type == 'Grotto':
forward_entrance.data['index'] = 0x1000 + forward_entrance.data['grotto_id']
- if return_entry:
- return_entry = return_entry[0]
- return_entrance = world.get_entrance(return_entry[0])
- return_entrance.data = return_entry[1]
- return_entrance.type = type
- forward_entrance.bind_two_way(return_entrance)
- if type == 'Grotto':
- return_entrance.data['index'] = 0x7FFF
+ return_entrance.data['index'] = 0x7FFF
+
+ for type, forward_entry in one_way_entrances:
+ forward_entrance = world.get_entrance(forward_entry[0])
+ forward_entrance.data = forward_entry[1]
+ forward_entrance.type = type
+ forward_entrance.primary = True
def assume_entrance_pool(entrance_pool: list[Entrance]) -> list[Entrance]:
@@ -61,8 +63,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 +92,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 = [
+two_way_entrances: list[tuple[str, tuple[str, EntranceData], tuple[str, EntranceData]]] = [
('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 }),
@@ -372,7 +378,9 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude
('Zoras Domain -> ZR Behind Waterfall', { 'index': 0x019D })),
('Overworld', ('ZD Behind King Zora -> Zoras Fountain', { 'index': 0x0225 }),
('Zoras Fountain -> ZD Behind King Zora', { 'index': 0x01A1 })),
+]
+one_way_entrances: list[tuple[str, tuple[str, EntranceData]]] = [
('OverworldOneWay', ('GV Lower Stream -> Lake Hylia', { 'index': 0x0219 })),
('OwlDrop', ('LH Owl Flight -> Hyrule Field', { 'index': 0x027E, 'addresses': [0xAC9F26] })),
@@ -426,7 +434,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:
@@ -454,8 +464,8 @@ def shuffle_random_entrances(worlds: list[World]) -> None:
# Shuffle all entrances within their own worlds
for world in worlds:
# Determine entrance pools based on settings, to be shuffled in the order we set them by
- one_way_entrance_pools = OrderedDict()
- entrance_pools = OrderedDict()
+ one_way_entrance_pools: dict[str, list[Entrance]] = {}
+ entrance_pools: dict[str, list[Entrance]] = {}
one_way_priorities = {}
if worlds[0].settings.shuffle_gerudo_valley_river_exit:
@@ -562,24 +572,23 @@ 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(
- entrance.connected_region is None for entrance in entrances)))())
+ target.add_rule(lambda state, **kwargs: any(entrance.connected_region is None for entrance in entrance_pool))
# Disconnect all one way entrances at this point (they need to be connected during all of the above process)
for entrance in chain.from_iterable(one_way_entrance_pools.values()):
entrance.disconnect()
@@ -709,7 +718,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():
@@ -744,7 +753,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.
@@ -768,7 +777,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:
@@ -785,7 +794,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
@@ -818,6 +829,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)
@@ -825,8 +838,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
@@ -847,6 +859,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':
@@ -889,12 +904,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
@@ -912,6 +932,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:
@@ -937,14 +958,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:
@@ -987,7 +1008,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])
@@ -1011,7 +1032,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
@@ -1025,6 +1046,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
@@ -1032,6 +1054,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:
@@ -1058,6 +1081,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)
@@ -1094,30 +1118,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 b2ef1d081..36ce36e7f 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
@@ -98,7 +100,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)
@@ -146,9 +148,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)
if worlds[0].settings.shuffle_mapcompass in ['any_dungeon', 'overworld', 'keysanity', 'regional']:
@@ -208,9 +215,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.')
@@ -224,9 +231,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:
@@ -305,6 +312,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)
@@ -329,6 +337,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.")
@@ -343,8 +352,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
@@ -412,6 +421,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:
@@ -444,6 +454,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
@@ -492,6 +503,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)
@@ -531,6 +543,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)
@@ -542,5 +555,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 47371eecd..16600e84f 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 BOSS_GOAL_TABLE, REWARD_GOAL_TABLE, 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
@@ -159,6 +165,7 @@ def replace_goal_names(worlds: list[World]) -> None:
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']:
flavor_text, clear_text, color = BOSS_GOAL_TABLE[boss.name]
if world.settings.clearer_hints:
@@ -186,11 +193,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 fd5cfe69a..22b460b1c 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,14 @@ 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)
+ assert location.item is not None
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:
@@ -104,6 +105,7 @@ def get_hint_group(group: str, world: World) -> list[Hint]:
if group in world.item_hint_type_overrides:
if name_is_location(name, hint.type, world):
location = world.get_location(name)
+ assert location.item is not None
if location.item.name in world.item_hint_type_overrides[group]:
type_override = True
elif name in multiTable.keys():
@@ -111,6 +113,7 @@ def get_hint_group(group: str, world: World) -> list[Hint]:
for locationName in multi.locations:
if locationName not in hint_exclusions(world):
location = world.get_location(locationName)
+ assert location.item is not None
if location.item.name in world.item_hint_type_overrides[group]:
type_override = True
@@ -148,6 +151,7 @@ def get_upgrade_hint_list(world: World, locations: list[str]) -> list[Hint]:
for locationName in multi.locations:
if locationName not in hint_exclusions(world):
location = world.get_location(locationName)
+ assert location.item is not None
if location.item.name in world.item_hint_type_overrides[hint_type]:
type_override = True
@@ -1950,18 +1954,17 @@ def rainbow_bridge_hint_kind(world: World) -> str:
# 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()]
@@ -1986,18 +1989,15 @@ def hint_exclusions(world: World, clear_cache: bool = False) -> list[str]:
'dual_always']):
multi = get_multi(hint.name)
exclude_hint = False
- for location in multi.locations:
- if location not in world_location_names or world.get_location(location).locked:
+ for location_name in multi.locations:
+ if location_name not in world_location_names or world.get_location(location_name).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:
@@ -2013,4 +2013,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 309cd4444..1a2c3d009 100644
--- a/Hints.py
+++ b/Hints.py
@@ -7,9 +7,9 @@
import sys
import urllib.request
from collections import OrderedDict, defaultdict
-from collections.abc import Callable, Iterable
+from collections.abc import Callable, Iterable, Sequence
from enum import Enum
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Optional, Any
from urllib.error import URLError, HTTPError
from HintList import Hint, get_hint, get_multi, get_hint_group, get_upgrade_hint_list, hint_exclusions, \
@@ -30,15 +30,14 @@
if TYPE_CHECKING:
from Dungeon import Dungeon
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], set[HintArea | str]], HintReturn]"
bingoBottlesForHints: set[str] = {
"Bottle", "Bottle with Red Potion", "Bottle with Green Potion", "Bottle with Blue Potion",
@@ -70,9 +69,9 @@
class RegionRestriction(Enum):
- NONE = 0,
- DUNGEON = 1,
- OVERWORLD = 2,
+ NONE = 0
+ DUNGEON = 1
+ OVERWORLD = 2
class GossipStone:
@@ -186,10 +185,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
@@ -302,8 +301,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
@@ -337,7 +335,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:
@@ -424,10 +422,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:
@@ -439,12 +438,14 @@ 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 or (use_alt_hint and parent_region.alt_hint)) and (original_parent.name == 'Root' or parent_region.name != 'Root'):
- if use_alt_hint and parent_region.alt_hint:
+ if original_parent.name == 'Root' or parent_region.name != 'Root':
+ if use_alt_hint and parent_region.alt_hint is not None:
return parent_region.alt_hint
- return parent_region.hint
+ if parent_region.hint is not None:
+ return parent_region.hint
for entrance in parent_region.entrances:
if entrance not in already_checked:
@@ -454,7 +455,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]:
@@ -504,8 +505,10 @@ def dungeon(self, world: World) -> Optional[Dungeon]:
dungeons = [dungeon for dungeon in world.dungeons if dungeon.name == self.dungeon_name]
if dungeons:
return dungeons[0]
+ return 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)
@@ -514,7 +517,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, use_2nd_person: 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)
@@ -543,22 +546,25 @@ def text(self, clearer_hints: bool, preposition: bool = False, use_2nd_person: b
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], all_checked: set[HintArea | str]) -> HintReturn:
+ locations = [
+ location
+ for location in spoiler.required_locations[world.id]
+ if location.name not in all_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)
- checked.add(location.name)
+ assert location.item is not None
+ all_checked.add(location.name)
hint_area = HintArea.at(location)
if hint_area.is_dungeon:
@@ -568,21 +574,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' and world.settings.shuffle_dungeon_rewards in ('vanilla', 'reward'):
return None
return HintArea.at(location)
- 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
@@ -617,7 +631,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], all_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)
@@ -625,13 +639,12 @@ 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:
- required_location[0].name not in checked
+ required_location[0].name not in all_checked
and required_location[0].name not in world.hint_exclusions
and required_location[0].name not in world.hint_type_overrides['goal']
and required_location[0].item.name not in world.item_hint_type_overrides['goal']
@@ -668,8 +681,9 @@ 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)
+ all_checked.add(location.name)
# Make sure this wasn't the last hintable location for other goals.
# If so, set weights to zero. This is important for one-hint-per-goal.
@@ -707,10 +721,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
@@ -770,9 +781,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], all_checked: set[HintArea | str]) -> HintReturn:
+ locations = [
+ location
+ for location in world.get_filled_locations()
+ if is_not_checked([location], all_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']
@@ -780,13 +794,14 @@ 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)
- checked.add(location.name)
+ assert location.item is not None
+ all_checked.add(location.name)
item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text
hint_area = HintArea.at(location)
@@ -798,7 +813,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], all_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.")
@@ -809,8 +824,9 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
if itemname == "Bottle" and world.settings.hint_dist == "bingo":
locations = [
location for location in world.get_filled_locations()
- if (is_not_checked([location], checked)
+ if (is_not_checked([location], all_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']
@@ -819,8 +835,9 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
else:
locations = [
location for location in world.get_filled_locations()
- if (is_not_checked([location], checked)
+ if (is_not_checked([location], all_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']
@@ -841,7 +858,8 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
return None
location = random.choice(locations)
- checked.add(location.name)
+ assert location.item is not None
+ all_checked.add(location.name)
item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text
hint_area = HintArea.at(location)
@@ -859,21 +877,23 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
while True:
# This operation is likely to be costly (especially for large multiworlds), so cache the result for later
# named_item_locations: Filtered locations from all worlds that may contain named-items
- try:
+ if spoiler._cached_named_item_locations is not None and spoiler._cached_always_locations is not None:
named_item_locations = spoiler._cached_named_item_locations
always_locations = spoiler._cached_always_locations
- except AttributeError:
+ else:
worlds = spoiler.worlds
all_named_items = set(itertools.chain.from_iterable([w.named_item_pool for w in worlds]))
if "Bottle" in all_named_items and world.settings.hint_dist == "bingo":
all_named_items.update(bingoBottlesForHints)
- named_item_locations = [location for w in worlds for location in w.get_filled_locations() if (location.item.name in all_named_items)]
+ named_item_locations = [location for w in worlds for location in w.get_filled_locations(lambda item: item.name in all_named_items)]
spoiler._cached_named_item_locations = named_item_locations
always_hints = [(hint, w.id) for w in worlds for hint in get_hint_group('always', w)]
always_locations = []
for hint, id in always_hints:
location = worlds[id].get_location(hint.name)
+ assert location.item is not None
+ assert location.item.world is not None
if location.item.name in bingoBottlesForHints and world.settings.hint_dist == 'bingo':
always_item = 'Bottle'
else:
@@ -885,7 +905,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
if itemname == "Bottle" and world.settings.hint_dist == "bingo":
locations = [
location for location in named_item_locations
- if (is_not_checked([location], checked)
+ if (is_not_checked([location], all_checked)
and location.item.world.id == world.id
and location.name not in world.hint_exclusions
and location.item.name in bingoBottlesForHints
@@ -896,7 +916,7 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
else:
locations = [
location for location in named_item_locations
- if (is_not_checked([location], checked)
+ if (is_not_checked([location], all_checked)
and location.item.world.id == world.id
and location.name not in world.hint_exclusions
and location.item.name == itemname
@@ -922,7 +942,9 @@ def get_specific_item_hint(spoiler: Spoiler, world: World, checked: set[str]) ->
return None
location = random.choice(locations)
- checked.add(location.name)
+ assert location.item is not None
+ assert location.world is not None
+ all_checked.add(location.name)
item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text
hint_area = HintArea.at(location)
@@ -937,22 +959,30 @@ 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:
- locations = list(filter(lambda location:
- is_not_checked([location], checked)
- and location.item.type not in ('Drop', 'Event', 'Shop')
- and not is_restricted_dungeon_item(location.item)
- 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']
- and (location.world.settings.empty_dungeons_mode == 'none' or not location.world.empty_dungeons[HintArea.at(location).dungeon_name].empty),
- world.get_filled_locations()))
+def get_random_location_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn:
+ locations: list[Location] = []
+ for location in world.get_filled_locations():
+ assert location.item is not None
+ if (
+ is_not_checked([location], checked)
+ and location.item.type not in ('Drop', 'Event', 'Shop')
+ and not is_restricted_dungeon_item(location.item)
+ 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']
+ ):
+ if world.settings.empty_dungeons_mode != 'none':
+ hint_area = HintArea.at(location)
+ if hint_area.dungeon_name is not None and world.empty_dungeons[hint_area.dungeon_name].empty:
+ continue
+ locations.append(location)
if not locations:
return None
location = random.choice(locations)
- checked.add(location.name)
+ assert location.item is not None
+ all_checked.add(location.name)
item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text
hint_area = HintArea.at(location)
@@ -964,13 +994,15 @@ 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:
def is_valid_hint(hint: Hint) -> bool:
location = world.get_location(hint.name)
- if not is_not_checked([world.get_location(hint.name)], checked):
- return False
- if location.world.settings.empty_dungeons_mode != 'none' and location.world.empty_dungeons[HintArea.at(location).dungeon_name].empty:
+ if not is_not_checked([location], checked):
return False
+ if world.settings.empty_dungeons_mode != 'none':
+ hint_area = HintArea.at(location)
+ if hint_area.dungeon_name is not None and world.empty_dungeons[hint_area.dungeon_name].empty:
+ return False
return True
hint_group = get_hint_group(hint_type, world)
@@ -999,6 +1031,7 @@ def is_valid_hint(hint: Hint) -> bool:
return get_specific_multi_hint(spoiler, world, checked, hint)
location = world.get_location(hint.name)
+ assert location.item is not None
checked.add(location.name)
if location.name in world.hint_text_overrides:
@@ -1012,23 +1045,23 @@ def is_valid_hint(hint: Hint) -> bool:
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:
- return get_specific_hint(spoiler, world, checked, 'sometimes')
+def get_sometimes_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn:
+ return get_specific_hint(spoiler, world, all_checked, 'sometimes')
-def get_song_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn:
- return get_specific_hint(spoiler, world, checked, 'song')
+def get_song_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn:
+ return get_specific_hint(spoiler, world, all_checked, 'song')
-def get_overworld_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn:
- return get_specific_hint(spoiler, world, checked, 'overworld')
+def get_overworld_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn:
+ return get_specific_hint(spoiler, world, all_checked, 'overworld')
-def get_dungeon_hint(spoiler: Spoiler, world: World, checked: set[str]) -> HintReturn:
- return get_specific_hint(spoiler, world, checked, 'dungeon')
+def get_dungeon_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn:
+ return get_specific_hint(spoiler, world, all_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))
@@ -1056,7 +1089,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]
@@ -1080,33 +1113,36 @@ def get_specific_multi_hint(spoiler: Spoiler, world: World, checked: set[str], h
else:
gossip_string = gossip_string + '#%s# '
- items = [location.item for location in locations]
+ items = [location.item for location in locations if location.item is not None]
text_segments = [multi_text] + [get_hint(get_item_generic_name(item), world.settings.clearer_hints).text for item in items]
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:
- return get_random_multi_hint(spoiler, world, checked, 'dual')
+def get_dual_hint(spoiler: Spoiler, world: World, checked: set[HintArea | str], all_checked: set[HintArea | str]) -> HintReturn:
+ return get_random_multi_hint(spoiler, world, all_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], all_checked: set[HintArea | str]) -> HintReturn:
if not world.entrance_shuffle:
return None
- entrance_hints = list(filter(lambda hint: hint.name not in checked, get_hint_group('entrance', world)))
+ entrance_hints = list(filter(lambda hint: hint.name not in all_checked, get_hint_group('entrance', world)))
shuffled_entrance_hints = list(filter(lambda entrance_hint: world.get_entrance(entrance_hint.name).shuffled, entrance_hints))
regions_with_hint = [hint.name for hint in get_hint_group('region', world)]
- valid_entrance_hints = list(filter(lambda entrance_hint:
- (world.get_entrance(entrance_hint.name).connected_region.name in regions_with_hint or
- world.get_entrance(entrance_hint.name).connected_region.dungeon), shuffled_entrance_hints))
+ valid_entrance_hints = []
+ for entrance_hint in shuffled_entrance_hints:
+ entrance = world.get_entrance(entrance_hint.name)
+ assert entrance.connected_region is not None
+ if entrance.connected_region.name in regions_with_hint or entrance.connected_region.dungeon:
+ valid_entrance_hints.append(entrance_hint)
if not valid_entrance_hints:
return None
entrance_hint = random.choice(valid_entrance_hints)
entrance = world.get_entrance(entrance_hint.name)
- checked.add(entrance.name)
+ all_checked.add(entrance.name)
entrance_text = entrance_hint.text
@@ -1114,7 +1150,8 @@ def get_entrance_hint(spoiler: Spoiler, world: World, checked: set[str]) -> Hint
entrance_text = '#%s#' % entrance_text
connected_region = entrance.connected_region
- if connected_region.dungeon:
+ assert connected_region is not None
+ if connected_region.dungeon is not None:
region_text = get_hint(connected_region.dungeon.name, world.settings.clearer_hints).text
else:
region_text = get_hint(connected_region.name, world.settings.clearer_hints).text
@@ -1125,28 +1162,29 @@ 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], all_checked: set[HintArea | str]) -> HintReturn:
hints = get_hint_group('junk', world)
- hints = list(filter(lambda hint: hint.name not in checked, hints))
+ hints = list(filter(lambda hint: hint.name not in all_checked, hints))
if not hints:
return None
hint = random.choice(hints)
- checked.add(hint.name)
+ all_checked.add(hint.name)
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], all_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
- and (HintArea.at(location).text(world.settings.clearer_hints) + ' Important Check') not in checked
+ and (HintArea.at(location).text(world.settings.clearer_hints) + ' Important Check') not in all_checked
and HintArea.at(location) != HintArea.ROOT):
top_level_locations.append(HintArea.at(location).text(world.settings.clearer_hints))
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
@@ -1169,7 +1207,7 @@ def get_important_check_hint(spoiler: Spoiler, world: World, checked: set[str])
or world.settings.shuffle_ganon_bosskey == 'dungeons' or world.settings.shuffle_ganon_bosskey == 'tokens'))):
item_count = item_count + 1
- checked.add(hint_loc + ' Important Check')
+ all_checked.add(hint_loc + ' Important Check')
if item_count == 0:
numcolor = 'Red'
@@ -1185,11 +1223,11 @@ def get_important_check_hint(spoiler: Spoiler, world: World, checked: set[str])
return GossipText('#%s# has #%d# major item%s.' % (hint_loc, item_count, "s" if item_count != 1 else ""), ['Green', numcolor]), None
-hint_func: dict[str, HintFunc | BarrenFunc] = {
- 'trial': lambda spoiler, world, checked: None,
- 'always': lambda spoiler, world, checked: None,
- 'dual_always': lambda spoiler, world, checked: None,
- 'entrance_always': lambda spoiler, world, checked: None,
+hint_func: dict[str, HintFunc] = {
+ 'trial': lambda spoiler, world, checked, all_checked: None,
+ 'always': lambda spoiler, world, checked, all_checked: None,
+ 'dual_always': lambda spoiler, world, checked, all_checked: None,
+ 'entrance_always': lambda spoiler, world, checked, all_checked: None,
'woth': get_woth_hint,
'goal': get_goal_hint,
'barren': get_barren_hint,
@@ -1231,7 +1269,7 @@ def build_bingo_hint_list(board_url: str) -> list[str]:
with open(data_path('Bingo/bingo_goals.json'), 'r') as bingoFile:
goal_hint_requirements = json.load(bingoFile)
- hints_to_add = {}
+ hints_to_add: dict[str, int] = {}
for goal in goal_list:
# Using 'get' here ensures some level of forward compatibility, where new goals added to randomiser bingo won't
# cause the generator to crash (though those hints won't have item hints for them)
@@ -1255,6 +1293,7 @@ def build_bingo_hint_list(board_url: str) -> list[str]:
def always_named_item(world: World, locations: Iterable[Location]):
for location in locations:
+ assert location.item is not None
if location.item.name in bingoBottlesForHints and world.settings.hint_dist == 'bingo':
always_item = 'Bottle'
else:
@@ -1266,39 +1305,37 @@ def always_named_item(world: World, locations: Iterable[Location]):
def build_gossip_hints(spoiler: Spoiler, worlds: list[World]) -> None:
from Dungeon import Dungeon
- 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():
if location is None:
# ignore starting items
continue
+ assert location.item is not None
+ assert location.world is not None
if world.settings.enhance_map_compass:
- if world.entrance_rando_reward_hints:
- # In these settings, there is not necessarily one dungeon reward in each dungeon,
- # so we instead have each compass hint the area of its dungeon's vanilla reward.
- compass_locations = [
- compass_location
- for compass_world in worlds
- for compass_location in compass_world.get_filled_locations()
- if Dungeon.from_vanilla_reward(location.item) is None # Light Medallion area is shown in menu from beginning of game
- or (
- compass_location.item.name == Dungeon.from_vanilla_reward(location.item).item_name('Compass')
- and compass_location.item.world == world
- )
- ]
- else:
- # Each compass hints which reward is in its dungeon.
- compass_locations = [
- compass_location
- for compass_world in worlds
- for compass_location in compass_world.get_filled_locations()
- if HintArea.at(location).dungeon_name is None # free/ToT reward is shown in menu from beginning of game
- or (
- compass_location.item.name == HintArea.at(location).dungeon(location.world).item_name('Compass')
- and compass_location.item.world == world
- )
- ]
+ compass_locations = []
+ for compass_world in worlds:
+ for compass_location in compass_world.get_filled_locations():
+ if world.entrance_rando_reward_hints:
+ # In these settings, there is not necessarily one dungeon reward in each dungeon,
+ # so we instead have each compass hint the area of its dungeon's vanilla reward.
+ dungeon = Dungeon.from_vanilla_reward(location.item)
+ else:
+ # Each compass hints which reward is in its dungeon.
+ dungeon = HintArea.at(location).dungeon(location.world)
+ if (
+ # with entrance_rando_reward_hints, Light Medallion area or free/ToT reward (otherwise) is shown in menu from beginning of game
+ # without entrance_rando_reward_hints, free/ToT reward is shown in menu from beginning of game
+ dungeon is None
+ or (
+ compass_location.item is not None
+ and compass_location.item.name == dungeon.item_name('Compass')
+ and compass_location.item.world == world
+ )
+ ):
+ compass_locations.append(compass_location)
for compass_location in compass_locations:
if can_reach_hint(worlds, compass_location, location):
item_world = location.world
@@ -1314,6 +1351,7 @@ def build_gossip_hints(spoiler: Spoiler, worlds: list[World]) -> None:
checked_locations[item_world.id].add(location.name)
for hint_type, location in world.misc_hint_item_locations.items():
if hint_type in world.settings.misc_hints and can_reach_hint(worlds, world.get_location(misc_item_hint_table[hint_type]['hint_location']), location):
+ assert location.world is not None
item_world = location.world
if item_world.id not in checked_locations:
checked_locations[item_world.id] = set()
@@ -1321,6 +1359,7 @@ def build_gossip_hints(spoiler: Spoiler, worlds: list[World]) -> None:
for hint_type in world.misc_hint_location_items.keys():
location = world.get_location(misc_location_hint_table[hint_type]['item_location'])
if hint_type in world.settings.misc_hints and can_reach_hint(worlds, world.get_location(misc_location_hint_table[hint_type]['hint_location']), location):
+ assert location.world is not None
item_world = location.world
if item_world.id not in checked_locations:
checked_locations[item_world.id] = set()
@@ -1328,7 +1367,7 @@ def build_gossip_hints(spoiler: Spoiler, worlds: list[World]) -> None:
for dungeon_name, info in world.empty_dungeons.items():
if info.empty:
for region in world.regions:
- if region.dungeon != None and region.dungeon.name == dungeon_name:
+ if region.dungeon is not None and region.dungeon.name == dungeon_name:
precompleted_locations = list(map(lambda location: location.name, region.locations))
checked_locations[world.id].update(precompleted_locations)
@@ -1339,7 +1378,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
@@ -1351,7 +1390,7 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
if checked_locations is None:
checked_locations = set()
- checked_always_locations = set()
+ checked_always_locations: set[HintArea | str] = set()
stone_ids = list(gossipLocations.keys())
@@ -1369,7 +1408,10 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
raise ValueError(f'Gossip stone location "{stone_name}" is not valid')
if stone_id in stone_ids:
stone_ids.remove(stone_id)
- (gossip_text, _) = get_junk_hint(spoiler, world, checked_locations)
+ all_checked_locations = checked_locations | checked_always_locations
+ hint_return = get_junk_hint(spoiler, world, checked_locations, all_checked_locations)
+ assert hint_return is not None
+ gossip_text, _ = hint_return
spoiler.hints[world.id][stone_id] = gossip_text
stone_groups = []
@@ -1460,6 +1502,8 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
hint_dist.move_to_end(hint_type)
fixed_hint_types.extend([hint_type] * int(fixed_num))
+ hint_types: Sequence[str]
+ hint_prob: Sequence[Any]
hint_types, hint_prob = zip(*hint_dist.items())
hint_prob, _ = zip(*hint_prob)
@@ -1469,7 +1513,9 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
for hint in always_duals:
multi = get_multi(hint.name)
first_location = world.get_location(multi.locations[0])
+ assert first_location.item is not None
second_location = world.get_location(multi.locations[1])
+ assert second_location.item is not None
checked_always_locations.add(first_location.name)
checked_always_locations.add(second_location.name)
@@ -1488,10 +1534,14 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
# Add required location hints, only if hint copies > 0
if hint_dist['always'][1] > 0:
- always_locations = list(filter(lambda hint: is_not_checked([world.get_location(hint.name)], checked_always_locations),
- get_hint_group('always', world)))
+ always_locations = [
+ hint
+ for hint in get_hint_group('always', world)
+ if is_not_checked([world.get_location(hint.name)], checked_always_locations)
+ ]
for hint in always_locations:
location = world.get_location(hint.name)
+ assert location.item is not None
checked_always_locations.add(hint.name)
always_named_item(world, [location])
@@ -1512,6 +1562,7 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
for entrance_hint in always_entrances:
entrance = world.get_entrance(entrance_hint.name)
connected_region = entrance.connected_region
+ assert connected_region is not None
if entrance.shuffled and (connected_region.dungeon or any(hint.name == connected_region.name for hint in
get_hint_group('region', world))):
checked_always_locations.add(entrance.name)
@@ -1559,19 +1610,25 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
# Prevent conflict between Ganondorf Light Arrows hint and required named item hints.
# Assumes that a "wasted" hint is desired since Light Arrows have to be added
# explicitly to the list for named item hints.
- filtered_checked = set(checked_locations | checked_always_locations)
- for location in (checked_locations | checked_always_locations):
- try:
- if world.get_location(location).item.name == 'Light Arrows':
- filtered_checked.remove(location)
- except KeyError:
- pass # checked_always_locations can also contain entrances from entrance_always hints, ignore those here
+ filtered_checked = set(checked_locations)
+ filtered_all_checked = set(checked_locations | checked_always_locations)
+ for checked_location_name in (checked_locations | checked_always_locations):
+ if isinstance(checked_location_name, str):
+ try:
+ checked_location = world.get_location(checked_location_name)
+ except KeyError:
+ pass # checked_always_locations can also contain entrances from entrance_always hints, ignore those here
+ else:
+ assert checked_location.item is not None
+ if checked_location.item.name == 'Light Arrows':
+ filtered_checked.discard(checked_location_name)
+ filtered_all_checked.discard(checked_location_name)
for i in range(0, len(world.named_item_pool)):
- hint = get_specific_item_hint(spoiler, world, filtered_checked)
- if hint:
+ hint_return = get_specific_item_hint(spoiler, world, filtered_checked, filtered_all_checked)
+ if hint_return is not None:
checked_locations.update(filtered_checked - checked_always_locations)
- gossip_text, location = hint
- place_ok = add_hint(spoiler, world, stone_groups, gossip_text, hint_dist['named-item'][1], location, hint_type='named-item')
+ gossip_text, locations = hint_return
+ place_ok = add_hint(spoiler, world, stone_groups, gossip_text, hint_dist['named-item'][1], locations, hint_type='named-item')
if not place_ok:
raise Exception('Not enough gossip stones for user-provided item hints')
@@ -1581,8 +1638,8 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
random.shuffle(world.named_item_pool)
hint_types = list(hint_types)
- hint_prob = list(hint_prob)
- hint_counts = {}
+ hint_prob = list(hint_prob)
+ hint_counts: dict[str, int] = {}
custom_fixed = True
while stone_groups:
@@ -1626,29 +1683,27 @@ def build_world_gossip_hints(spoiler: Spoiler, world: World, checked_locations:
raise Exception('Not enough valid hints to fill gossip stone locations.')
all_checked_locations = checked_locations | checked_always_locations
- if hint_type == 'barren':
- hint = hint_func[hint_type](spoiler, world, checked_locations, all_checked_locations)
- else:
- hint = hint_func[hint_type](spoiler, world, all_checked_locations)
+ hint_return = hint_func[hint_type](spoiler, world, checked_locations, all_checked_locations)
+ if hint_type != 'barren':
checked_locations.update(all_checked_locations - checked_always_locations)
- if hint is None:
+ if hint_return is None:
index = hint_types.index(hint_type)
hint_prob[index] = 0
# Zero out the probability in the base distribution in case the probability list is modified
# to fit hint types in remaining gossip stones
hint_dist[hint_type] = (0.0, copies)
else:
- gossip_text, locations = hint
+ gossip_text, locations = hint_return
place_ok = add_hint(spoiler, world, stone_groups, gossip_text, copies, locations, hint_type=hint_type)
if place_ok:
hint_counts[hint_type] = hint_counts.get(hint_type, 0) + 1
if locations is None:
- logging.getLogger('').debug('Placed %s hint.', hint_type)
+ logging.getLogger('').debug(f'Placed {hint_type} hint.')
else:
- logging.getLogger('').debug('Placed %s hint for %s.', hint_type, ', '.join([location.name for location in locations]))
+ logging.getLogger('').debug(f'Placed {hint_type} hint for {locations}.')
if not place_ok and custom_fixed:
- logging.getLogger('').debug('Failed to place %s fixed hint for %s.', hint_type, ', '.join([location.name for location in locations]))
+ logging.getLogger('').debug(f'Failed to place {hint_type} fixed hint for {locations}.')
fixed_hint_types.insert(0, hint_type)
@@ -1705,9 +1760,12 @@ def build_boss_string(reward: str, color: str, world: World) -> str:
location = world.hinted_dungeon_reward_locations[reward]
if location is None:
hint_area = HintArea.ROOT
+ world_id = world.id
else:
+ assert location.world is not None
hint_area = HintArea.at(location)
- location_text = hint_area.text(world.settings.clearer_hints, preposition=True, world=None if location.world.id == world.id else location.world.id + 1)
+ world_id = location.world.id
+ location_text = hint_area.text(world.settings.clearer_hints, preposition=True, world=None if world_id == world.id else world_id + 1)
text = GossipText(f"\x08\x13{item_icon}One {location_text}...", [color], prefix='')
return str(text) + '\x04'
@@ -1795,6 +1853,8 @@ def build_misc_item_hints(world: World, messages: list[Message], allow_duplicate
text = data['custom_item_text'].format(area='#your pocket#', item=item)
elif hint_type in world.misc_hint_item_locations:
location = world.misc_hint_item_locations[hint_type]
+ assert location.item is not None
+ assert location.world is not None
area = HintArea.at(location, use_alt_hint=data['use_alt_hint']).text(world.settings.clearer_hints, world=None if location.world.id == world.id else location.world.id + 1)
if item == data['default_item']:
text = data['default_item_text'].format(area=area)
@@ -1808,6 +1868,7 @@ def build_misc_item_hints(world: World, messages: list[Message], allow_duplicate
else:
text = get_hint('Validation Line', world.settings.clearer_hints).text
for location in world.get_filled_locations():
+ assert location.item is not None
if location.name == 'Ganons Tower Boss Key Chest':
text += f"#{get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text}#"
break
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 f233ee774..7cc1bc03b 100644
--- a/Item.py
+++ b/Item.py
@@ -28,17 +28,17 @@ class ItemInfo:
def __init__(self, name: str = '', event: bool = False) -> None:
if event:
item_type = 'Event'
- progressive = True
+ progressive: Optional[bool] = True
item_id = None
- special = None
+ special: dict[str, Any] = {}
else:
- (item_type, progressive, item_id, special) = item_table[name]
+ item_type, progressive, item_id, special = item_table[name]
self.name: str = name
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)
@@ -201,11 +201,11 @@ def ItemFactory(items: str, world: Optional[World] = None, event: bool = False)
@overload
-def ItemFactory(items: Iterable[str], world: Optional[World] = None, event: bool = False) -> list[Item]:
+def ItemFactory(items: list[str] | tuple[str], world: Optional[World] = None, event: bool = False) -> list[Item]:
pass
-def ItemFactory(items: str | Iterable[str], world: Optional[World] = None, event: bool = False) -> Item | list[Item]:
+def ItemFactory(items: str | list[str] | tuple[str], world: Optional[World] = None, event: bool = False) -> Item | list[Item]:
if isinstance(items, str):
if not event and items not in ItemInfo.items:
raise KeyError('Unknown Item: %s' % items)
diff --git a/ItemList.py b/ItemList.py
index 0d13f44c9..436d72aec 100644
--- a/ItemList.py
+++ b/ItemList.py
@@ -330,19 +330,19 @@ class GetItemId(IntEnum):
# 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, GetItemId.GI_BOMBS_5, {'junk': 8}),
'Deku Nuts (5)': ('Item', None, GetItemId.GI_DEKU_NUTS_5, {'junk': 5}),
- 'Bombchus (10)': ('Item', True, GetItemId.GI_BOMBCHUS_10, None),
- 'Boomerang': ('Item', True, GetItemId.GI_BOOMERANG, None),
+ 'Bombchus (10)': ('Item', True, GetItemId.GI_BOMBCHUS_10, {}),
+ 'Boomerang': ('Item', True, GetItemId.GI_BOOMERANG, {}),
'Deku Stick (1)': ('Item', None, GetItemId.GI_DEKU_STICKS_1, {'junk': 5}),
- 'Lens of Truth': ('Item', True, GetItemId.GI_LENS_OF_TRUTH, None),
- 'Megaton Hammer': ('Item', True, GetItemId.GI_HAMMER, None),
+ 'Lens of Truth': ('Item', True, GetItemId.GI_LENS_OF_TRUTH, {}),
+ 'Megaton Hammer': ('Item', True, GetItemId.GI_HAMMER, {}),
'Cojiro': ('Item', True, GetItemId.GI_COJIRO, {'trade': True}),
'Bottle': ('Item', True, GetItemId.GI_BOTTLE_EMPTY, {'bottle': float('Inf')}),
- 'Blue Potion': ('Item', True, GetItemId.GI_BOTTLE_POTION_BLUE, None), # distinct from shop item
+ 'Blue Potion': ('Item', True, GetItemId.GI_BOTTLE_POTION_BLUE, {}), # distinct from shop item
'Bottle with Milk': ('Item', True, GetItemId.GI_BOTTLE_MILK_FULL, {'bottle': float('Inf')}),
- 'Rutos Letter': ('Item', True, GetItemId.GI_BOTTLE_RUTOS_LETTER, None),
+ 'Rutos Letter': ('Item', True, GetItemId.GI_BOTTLE_RUTOS_LETTER, {}),
'Deliver Letter': ('Item', True, None, {'bottle': float('Inf')}),
'Sell Big Poe': ('Item', True, None, {'bottle': float('Inf')}),
'Magic Bean': ('Item', True, GetItemId.GI_MAGIC_BEAN, {'progressive': 10}),
@@ -362,23 +362,23 @@ class GetItemId(IntEnum):
'Eyeball Frog': ('Item', True, GetItemId.GI_EYEBALL_FROG, {'trade': True}),
'Eyedrops': ('Item', True, GetItemId.GI_EYE_DROPS, {'trade': True}),
'Claim Check': ('Item', True, GetItemId.GI_CLAIM_CHECK, {'trade': True}),
- 'Kokiri Sword': ('Item', True, GetItemId.GI_SWORD_KOKIRI, None),
- 'Giants Knife': ('Item', None, GetItemId.GI_SWORD_KNIFE, None),
- 'Deku Shield': ('Item', None, GetItemId.GI_SHIELD_DEKU, None),
- 'Hylian Shield': ('Item', None, GetItemId.GI_SHIELD_HYLIAN, None),
- 'Mirror Shield': ('Item', True, GetItemId.GI_SHIELD_MIRROR, None),
- 'Goron Tunic': ('Item', True, GetItemId.GI_TUNIC_GORON, None),
- 'Zora Tunic': ('Item', True, GetItemId.GI_TUNIC_ZORA, None),
- 'Iron Boots': ('Item', True, GetItemId.GI_BOOTS_IRON, None),
- 'Hover Boots': ('Item', True, GetItemId.GI_BOOTS_HOVER, None),
- 'Stone of Agony': ('Item', True, GetItemId.GI_STONE_OF_AGONY, None),
- 'Gerudo Membership Card': ('Item', True, GetItemId.GI_GERUDOS_CARD, None),
+ 'Kokiri Sword': ('Item', True, GetItemId.GI_SWORD_KOKIRI, {}),
+ 'Giants Knife': ('Item', None, GetItemId.GI_SWORD_KNIFE, {}),
+ 'Deku Shield': ('Item', None, GetItemId.GI_SHIELD_DEKU, {}),
+ 'Hylian Shield': ('Item', None, GetItemId.GI_SHIELD_HYLIAN, {}),
+ 'Mirror Shield': ('Item', True, GetItemId.GI_SHIELD_MIRROR, {}),
+ 'Goron Tunic': ('Item', True, GetItemId.GI_TUNIC_GORON, {}),
+ 'Zora Tunic': ('Item', True, GetItemId.GI_TUNIC_ZORA, {}),
+ 'Iron Boots': ('Item', True, GetItemId.GI_BOOTS_IRON, {}),
+ 'Hover Boots': ('Item', True, GetItemId.GI_BOOTS_HOVER, {}),
+ 'Stone of Agony': ('Item', True, GetItemId.GI_STONE_OF_AGONY, {}),
+ 'Gerudo Membership Card': ('Item', True, GetItemId.GI_GERUDOS_CARD, {}),
'Heart Container': ('Item', True, GetItemId.GI_HEART_CONTAINER, {'alias': ('Piece of Heart', 4), 'progressive': float('Inf')}),
'Piece of Heart': ('Item', True, GetItemId.GI_HEART_PIECE, {'progressive': float('Inf')}),
- 'Piece of Heart (Out of Logic)': ('Item', None, GetItemId.GI_HEART_PIECE, None),
- 'Boss Key': ('BossKey', True, GetItemId.GI_BOSS_KEY, None),
- 'Compass': ('Compass', None, GetItemId.GI_COMPASS, None),
- 'Map': ('Map', None, GetItemId.GI_DUNGEON_MAP, None),
+ 'Piece of Heart (Out of Logic)': ('Item', None, GetItemId.GI_HEART_PIECE, {}),
+ 'Boss Key': ('BossKey', True, GetItemId.GI_BOSS_KEY, {}),
+ 'Compass': ('Compass', None, GetItemId.GI_COMPASS, {}),
+ 'Map': ('Map', None, GetItemId.GI_DUNGEON_MAP, {}),
'Small Key': ('SmallKey', True, GetItemId.GI_SMALL_KEY, {'progressive': float('Inf')}),
'Weird Egg': ('Item', True, GetItemId.GI_WEIRD_EGG, {'trade': True}),
'Recovery Heart': ('Item', None, GetItemId.GI_RECOVERY_HEART, {'junk': 0}),
@@ -388,47 +388,47 @@ class GetItemId(IntEnum):
'Rupee (1)': ('Item', None, GetItemId.GI_RUPEE_GREEN, {'junk': -1}),
'Rupees (5)': ('Item', None, GetItemId.GI_RUPEE_BLUE, {'junk': 10}),
'Rupees (20)': ('Item', None, GetItemId.GI_RUPEE_RED, {'junk': 4}),
- 'Milk': ('Item', None, GetItemId.GI_MILK, None),
+ 'Milk': ('Item', None, GetItemId.GI_MILK, {}),
'Goron Mask': ('Item', None, GetItemId.GI_MASK_GORON, {'trade': True, 'object': 0x0150}),
'Zora Mask': ('Item', None, GetItemId.GI_MASK_ZORA, {'trade': True, 'object': 0x0151}),
'Gerudo Mask': ('Item', None, GetItemId.GI_MASK_GERUDO, {'trade': True, 'object': 0x0152}),
'Rupees (50)': ('Item', None, GetItemId.GI_RUPEE_PURPLE, {'junk': 1}),
'Rupees (200)': ('Item', None, GetItemId.GI_RUPEE_GOLD, {'junk': 0}),
- 'Biggoron Sword': ('Item', None, GetItemId.GI_SWORD_BIGGORON, None),
- 'Fire Arrows': ('Item', True, GetItemId.GI_ARROW_FIRE, None),
- 'Ice Arrows': ('Item', True, GetItemId.GI_ARROW_ICE, None),
- 'Blue Fire Arrows': ('Item', True, GetItemId.GI_ARROW_ICE, None),
- 'Light Arrows': ('Item', True, GetItemId.GI_ARROW_LIGHT, None),
+ 'Biggoron Sword': ('Item', None, GetItemId.GI_SWORD_BIGGORON, {}),
+ 'Fire Arrows': ('Item', True, GetItemId.GI_ARROW_FIRE, {}),
+ 'Ice Arrows': ('Item', True, GetItemId.GI_ARROW_ICE, {}),
+ 'Blue Fire Arrows': ('Item', True, GetItemId.GI_ARROW_ICE, {}),
+ 'Light Arrows': ('Item', True, GetItemId.GI_ARROW_LIGHT, {}),
'Gold Skulltula Token': ('Token', True, GetItemId.GI_SKULL_TOKEN, {'progressive': float('Inf')}),
- 'Dins Fire': ('Item', True, GetItemId.GI_DINS_FIRE, None),
- 'Farores Wind': ('Item', True, GetItemId.GI_FARORES_WIND, None),
- 'Nayrus Love': ('Item', True, GetItemId.GI_NAYRUS_LOVE, None),
+ 'Dins Fire': ('Item', True, GetItemId.GI_DINS_FIRE, {}),
+ 'Farores Wind': ('Item', True, GetItemId.GI_FARORES_WIND, {}),
+ 'Nayrus Love': ('Item', True, GetItemId.GI_NAYRUS_LOVE, {}),
'Deku Nuts (10)': ('Item', None, GetItemId.GI_DEKU_NUTS_10, {'junk': 0}),
'Bomb (1)': ('Item', None, GetItemId.GI_BOMBS_1, {'junk': -1}),
'Bombs (10)': ('Item', None, GetItemId.GI_BOMBS_10, {'junk': 2}),
'Bombs (20)': ('Item', None, GetItemId.GI_BOMBS_20, {'junk': 0}),
'Deku Seeds (30)': ('Item', None, GetItemId.GI_DEKU_SEEDS_30, {'junk': 5}),
- 'Bombchus (5)': ('Item', True, GetItemId.GI_BOMBCHUS_5, None),
- 'Bombchus (20)': ('Item', True, GetItemId.GI_BOMBCHUS_20, None),
+ 'Bombchus (5)': ('Item', True, GetItemId.GI_BOMBCHUS_5, {}),
+ 'Bombchus (20)': ('Item', True, GetItemId.GI_BOMBCHUS_20, {}),
'Small Key (Treasure Chest Game)': ('TCGSmallKey', True, GetItemId.GI_DOOR_KEY, {'progressive': float('Inf')}),
- 'Rupee (Treasure Chest Game) (1)': ('Item', None, GetItemId.GI_RUPEE_GREEN_LOSE, None),
- 'Rupees (Treasure Chest Game) (5)': ('Item', None, GetItemId.GI_RUPEE_BLUE_LOSE, None),
- 'Rupees (Treasure Chest Game) (20)': ('Item', None, GetItemId.GI_RUPEE_RED_LOSE, None),
- 'Rupees (Treasure Chest Game) (50)': ('Item', None, GetItemId.GI_RUPEE_PURPLE_LOSE, None),
+ 'Rupee (Treasure Chest Game) (1)': ('Item', None, GetItemId.GI_RUPEE_GREEN_LOSE, {}),
+ 'Rupees (Treasure Chest Game) (5)': ('Item', None, GetItemId.GI_RUPEE_BLUE_LOSE, {}),
+ 'Rupees (Treasure Chest Game) (20)': ('Item', None, GetItemId.GI_RUPEE_RED_LOSE, {}),
+ 'Rupees (Treasure Chest Game) (50)': ('Item', None, GetItemId.GI_RUPEE_PURPLE_LOSE, {}),
'Piece of Heart (Treasure Chest Game)': ('Item', True, GetItemId.GI_HEART_PIECE_WIN, {'alias': ('Piece of Heart', 1), 'progressive': float('Inf')}),
'Ice Trap': ('Item', None, GetItemId.GI_ICE_TRAP, {'junk': 0}),
'Progressive Hookshot': ('Item', True, GetItemId.GI_PROGRESSIVE_HOOKSHOT, {'progressive': 2}),
'Progressive Strength Upgrade': ('Item', True, GetItemId.GI_PROGRESSIVE_STRENGTH, {'progressive': 3}),
- 'Bomb Bag': ('Item', True, GetItemId.GI_PROGRESSIVE_BOMB_BAG, None),
- 'Bow': ('Item', True, GetItemId.GI_PROGRESSIVE_BOW, None),
- 'Slingshot': ('Item', True, GetItemId.GI_PROGRESSIVE_SLINGSHOT, None),
+ 'Bomb Bag': ('Item', True, GetItemId.GI_PROGRESSIVE_BOMB_BAG, {}),
+ 'Bow': ('Item', True, GetItemId.GI_PROGRESSIVE_BOW, {}),
+ 'Slingshot': ('Item', True, GetItemId.GI_PROGRESSIVE_SLINGSHOT, {}),
'Progressive Wallet': ('Item', True, GetItemId.GI_PROGRESSIVE_WALLET, {'progressive': 3}),
'Progressive Scale': ('Item', True, GetItemId.GI_PROGRESSIVE_SCALE, {'progressive': 2}),
- 'Deku Nut Capacity': ('Item', None, GetItemId.GI_PROGRESSIVE_NUT_CAPACITY, None),
- 'Deku Stick Capacity': ('Item', None, GetItemId.GI_PROGRESSIVE_STICK_CAPACITY, None),
- 'Bombchus': ('Item', True, GetItemId.GI_PROGRESSIVE_BOMBCHUS, None),
- 'Magic Meter': ('Item', True, GetItemId.GI_PROGRESSIVE_MAGIC_METER, None),
- 'Ocarina': ('Item', True, GetItemId.GI_PROGRESSIVE_OCARINA, None),
+ 'Deku Nut Capacity': ('Item', None, GetItemId.GI_PROGRESSIVE_NUT_CAPACITY, {}),
+ 'Deku Stick Capacity': ('Item', None, GetItemId.GI_PROGRESSIVE_STICK_CAPACITY, {}),
+ 'Bombchus': ('Item', True, GetItemId.GI_PROGRESSIVE_BOMBCHUS, {}),
+ 'Magic Meter': ('Item', True, GetItemId.GI_PROGRESSIVE_MAGIC_METER, {}),
+ 'Ocarina': ('Item', True, GetItemId.GI_PROGRESSIVE_OCARINA, {}),
'Bottle with Red Potion': ('Item', True, GetItemId.GI_BOTTLE_WITH_RED_POTION, {'bottle': True, 'shop_object': 0x0F}),
'Bottle with Green Potion': ('Item', True, GetItemId.GI_BOTTLE_WITH_GREEN_POTION, {'bottle': True, 'shop_object': 0x0F}),
'Bottle with Blue Potion': ('Item', True, GetItemId.GI_BOTTLE_WITH_BLUE_POTION, {'bottle': True, 'shop_object': 0x0F}),
@@ -438,32 +438,32 @@ class GetItemId(IntEnum):
'Bottle with Bugs': ('Item', True, GetItemId.GI_BOTTLE_WITH_BUGS, {'bottle': True, 'shop_object': 0x0F}),
'Bottle with Big Poe': ('Item', True, GetItemId.GI_BOTTLE_WITH_BIG_POE, {'shop_object': 0x0F}),
'Bottle with Poe': ('Item', True, GetItemId.GI_BOTTLE_WITH_POE, {'bottle': True, 'shop_object': 0x0F}),
- 'Boss Key (Forest Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_FOREST_TEMPLE, None),
- 'Boss Key (Fire Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_FIRE_TEMPLE, None),
- 'Boss Key (Water Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_WATER_TEMPLE, None),
- 'Boss Key (Spirit Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_SPIRIT_TEMPLE, None),
- 'Boss Key (Shadow Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_SHADOW_TEMPLE, None),
- 'Boss Key (Ganons Castle)': ('GanonBossKey', True, GetItemId.GI_BOSS_KEY_GANONS_CASTLE, None),
- 'Compass (Deku Tree)': ('Compass', False, GetItemId.GI_COMPASS_DEKU_TREE, None),
- 'Compass (Dodongos Cavern)': ('Compass', False, GetItemId.GI_COMPASS_DODONGOS_CAVERN, None),
- 'Compass (Jabu Jabus Belly)': ('Compass', False, GetItemId.GI_COMPASS_JABU_JABU, None),
- 'Compass (Forest Temple)': ('Compass', False, GetItemId.GI_COMPASS_FOREST_TEMPLE, None),
- 'Compass (Fire Temple)': ('Compass', False, GetItemId.GI_COMPASS_FIRE_TEMPLE, None),
- 'Compass (Water Temple)': ('Compass', False, GetItemId.GI_COMPASS_WATER_TEMPLE, None),
- 'Compass (Spirit Temple)': ('Compass', False, GetItemId.GI_COMPASS_SPIRIT_TEMPLE, None),
- 'Compass (Shadow Temple)': ('Compass', False, GetItemId.GI_COMPASS_SHADOW_TEMPLE, None),
- 'Compass (Bottom of the Well)': ('Compass', False, GetItemId.GI_COMPASS_BOTTOM_OF_THE_WELL, None),
- 'Compass (Ice Cavern)': ('Compass', False, GetItemId.GI_COMPASS_ICE_CAVERN, None),
- 'Map (Deku Tree)': ('Map', False, GetItemId.GI_MAP_DEKU_TREE, None),
- 'Map (Dodongos Cavern)': ('Map', False, GetItemId.GI_MAP_DODONGOS_CAVERN, None),
- 'Map (Jabu Jabus Belly)': ('Map', False, GetItemId.GI_MAP_JABU_JABU, None),
- 'Map (Forest Temple)': ('Map', False, GetItemId.GI_MAP_FOREST_TEMPLE, None),
- 'Map (Fire Temple)': ('Map', False, GetItemId.GI_MAP_FIRE_TEMPLE, None),
- 'Map (Water Temple)': ('Map', False, GetItemId.GI_MAP_WATER_TEMPLE, None),
- 'Map (Spirit Temple)': ('Map', False, GetItemId.GI_MAP_SPIRIT_TEMPLE, None),
- 'Map (Shadow Temple)': ('Map', False, GetItemId.GI_MAP_SHADOW_TEMPLE, None),
- 'Map (Bottom of the Well)': ('Map', False, GetItemId.GI_MAP_BOTTOM_OF_THE_WELL, None),
- 'Map (Ice Cavern)': ('Map', False, GetItemId.GI_MAP_ICE_CAVERN, None),
+ 'Boss Key (Forest Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_FOREST_TEMPLE, {}),
+ 'Boss Key (Fire Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_FIRE_TEMPLE, {}),
+ 'Boss Key (Water Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_WATER_TEMPLE, {}),
+ 'Boss Key (Spirit Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_SPIRIT_TEMPLE, {}),
+ 'Boss Key (Shadow Temple)': ('BossKey', True, GetItemId.GI_BOSS_KEY_SHADOW_TEMPLE, {}),
+ 'Boss Key (Ganons Castle)': ('GanonBossKey', True, GetItemId.GI_BOSS_KEY_GANONS_CASTLE, {}),
+ 'Compass (Deku Tree)': ('Compass', False, GetItemId.GI_COMPASS_DEKU_TREE, {}),
+ 'Compass (Dodongos Cavern)': ('Compass', False, GetItemId.GI_COMPASS_DODONGOS_CAVERN, {}),
+ 'Compass (Jabu Jabus Belly)': ('Compass', False, GetItemId.GI_COMPASS_JABU_JABU, {}),
+ 'Compass (Forest Temple)': ('Compass', False, GetItemId.GI_COMPASS_FOREST_TEMPLE, {}),
+ 'Compass (Fire Temple)': ('Compass', False, GetItemId.GI_COMPASS_FIRE_TEMPLE, {}),
+ 'Compass (Water Temple)': ('Compass', False, GetItemId.GI_COMPASS_WATER_TEMPLE, {}),
+ 'Compass (Spirit Temple)': ('Compass', False, GetItemId.GI_COMPASS_SPIRIT_TEMPLE, {}),
+ 'Compass (Shadow Temple)': ('Compass', False, GetItemId.GI_COMPASS_SHADOW_TEMPLE, {}),
+ 'Compass (Bottom of the Well)': ('Compass', False, GetItemId.GI_COMPASS_BOTTOM_OF_THE_WELL, {}),
+ 'Compass (Ice Cavern)': ('Compass', False, GetItemId.GI_COMPASS_ICE_CAVERN, {}),
+ 'Map (Deku Tree)': ('Map', False, GetItemId.GI_MAP_DEKU_TREE, {}),
+ 'Map (Dodongos Cavern)': ('Map', False, GetItemId.GI_MAP_DODONGOS_CAVERN, {}),
+ 'Map (Jabu Jabus Belly)': ('Map', False, GetItemId.GI_MAP_JABU_JABU, {}),
+ 'Map (Forest Temple)': ('Map', False, GetItemId.GI_MAP_FOREST_TEMPLE, {}),
+ 'Map (Fire Temple)': ('Map', False, GetItemId.GI_MAP_FIRE_TEMPLE, {}),
+ 'Map (Water Temple)': ('Map', False, GetItemId.GI_MAP_WATER_TEMPLE, {}),
+ 'Map (Spirit Temple)': ('Map', False, GetItemId.GI_MAP_SPIRIT_TEMPLE, {}),
+ 'Map (Shadow Temple)': ('Map', False, GetItemId.GI_MAP_SHADOW_TEMPLE, {}),
+ 'Map (Bottom of the Well)': ('Map', False, GetItemId.GI_MAP_BOTTOM_OF_THE_WELL, {}),
+ 'Map (Ice Cavern)': ('Map', False, GetItemId.GI_MAP_ICE_CAVERN, {}),
'Small Key (Forest Temple)': ('SmallKey', True, GetItemId.GI_SMALL_KEY_FOREST_TEMPLE, {'progressive': float('Inf')}),
'Small Key (Fire Temple)': ('SmallKey', True, GetItemId.GI_SMALL_KEY_FIRE_TEMPLE, {'progressive': float('Inf')}),
'Small Key (Water Temple)': ('SmallKey', True, GetItemId.GI_SMALL_KEY_WATER_TEMPLE, {'progressive': float('Inf')}),
@@ -473,14 +473,14 @@ class GetItemId(IntEnum):
'Small Key (Gerudo Training Ground)': ('SmallKey', True, GetItemId.GI_SMALL_KEY_GERUDO_TRAINING, {'progressive': float('Inf')}),
'Small Key (Thieves Hideout)': ('HideoutSmallKey', True, GetItemId.GI_SMALL_KEY_THIEVES_HIDEOUT, {'progressive': float('Inf')}),
'Small Key (Ganons Castle)': ('SmallKey', True, GetItemId.GI_SMALL_KEY_GANONS_CASTLE, {'progressive': float('Inf')}),
- 'Double Defense': ('Item', None, GetItemId.GI_DOUBLE_DEFENSE, None),
+ 'Double Defense': ('Item', None, GetItemId.GI_DOUBLE_DEFENSE, {}),
'Buy Magic Bean': ('Item', True, GetItemId.GI_MAGIC_BEAN, {'alias': ('Magic Bean', 10), 'progressive': 10}),
'Magic Bean Pack': ('Item', True, GetItemId.GI_MAGIC_BEAN_PACK, {'alias': ('Magic Bean', 10), 'progressive': 10}),
'Triforce Piece': ('Item', True, GetItemId.GI_TRIFORCE_PIECE, {'progressive': float('Inf')}),
'Zeldas Letter': ('Item', True, GetItemId.GI_ZELDAS_LETTER, {'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)': ('SmallKeyRing', True, GetItemId.GI_SMALL_KEY_RING_FOREST_TEMPLE, {'alias': ('Small Key (Forest Temple)', 10), 'progressive': float('Inf')}),
'Small Key Ring (Fire Temple)': ('SmallKeyRing', True, GetItemId.GI_SMALL_KEY_RING_FIRE_TEMPLE, {'alias': ('Small Key (Fire Temple)', 10), 'progressive': float('Inf')}),
@@ -544,37 +544,37 @@ class GetItemId(IntEnum):
'Ocarina C down Button': ('Item', True, GetItemId.GI_OCARINA_BUTTON_C_DOWN, {'ocarina_button': True}),
'Ocarina C left Button': ('Item', True, GetItemId.GI_OCARINA_BUTTON_C_LEFT, {'ocarina_button': True}),
'Ocarina C right Button': ('Item', True, GetItemId.GI_OCARINA_BUTTON_C_RIGHT, {'ocarina_button': True}),
- 'Fairy Drop': ('Item', None, GetItemId.GI_FAIRY, None),
- 'Nothing': ('Item', None, GetItemId.GI_NOTHING, None),
+ 'Fairy Drop': ('Item', None, GetItemId.GI_FAIRY, {}),
+ 'Nothing': ('Item', None, GetItemId.GI_NOTHING, {}),
# 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),
-
- '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),
+ '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, {}),
+ '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, GetItemId.GI_MINUET_OF_FOREST,
{
diff --git a/ItemPool.py b/ItemPool.py
index be6b7f823..0a44f2bba 100644
--- a/ItemPool.py
+++ b/ItemPool.py
@@ -430,7 +430,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()))
@@ -509,16 +509,16 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
if world.settings.shuffle_gerudo_card:
pending_junk_pool.append('Gerudo Membership Card')
if world.settings.shuffle_smallkeys in ('any_dungeon', 'overworld', 'keysanity', 'regional'):
- for dungeon in ('Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple',
+ for dungeon_name in ('Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple',
'Bottom of the Well', 'Gerudo Training Ground', 'Ganons Castle'):
- if world.keyring(dungeon):
- pending_junk_pool.append(f"Small Key Ring ({dungeon})")
+ if world.keyring(dungeon_name):
+ pending_junk_pool.append(f"Small Key Ring ({dungeon_name})")
else:
- pending_junk_pool.append(f"Small Key ({dungeon})")
+ pending_junk_pool.append(f"Small Key ({dungeon_name})")
if world.settings.shuffle_bosskeys in ('any_dungeon', 'overworld', 'keysanity', 'regional'):
- for dungeon in ('Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple'):
- if not world.keyring_give_bk(dungeon):
- pending_junk_pool.append(f"Boss Key ({dungeon})")
+ for dungeon_name in ('Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple'):
+ if not world.keyring_give_bk(dungeon_name):
+ pending_junk_pool.append(f"Boss Key ({dungeon_name})")
if world.settings.shuffle_ganon_bosskey in ('any_dungeon', 'overworld', 'keysanity', 'regional'):
pending_junk_pool.append('Boss Key (Ganons Castle)')
if world.settings.shuffle_silver_rupees in ('any_dungeon', 'overworld', 'anywhere', 'regional'):
@@ -544,7 +544,7 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
pending_junk_pool.append('Ocarina C right Button')
# Use the vanilla items in the world's locations when appropriate.
- vanilla_items_processed = Counter()
+ vanilla_items_processed: Counter[str] = Counter()
for location in world.get_locations():
if location.vanilla_item is None:
continue
@@ -560,9 +560,11 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
# Gold Skulltula Tokens
elif location.vanilla_item == 'Gold Skulltula Token':
- shuffle_item = (world.settings.tokensanity == 'all'
- or (world.settings.tokensanity == 'dungeons' and location.dungeon)
- or (world.settings.tokensanity == 'overworld' and not location.dungeon))
+ shuffle_item = (
+ world.settings.tokensanity == 'all'
+ or (world.settings.tokensanity == 'dungeons' and location.dungeon is not None)
+ or (world.settings.tokensanity == 'overworld' and location.dungeon is None)
+ )
# Shops
elif location.type == "Shop":
@@ -578,9 +580,11 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
elif world.settings.shuffle_scrubs == 'off':
shuffle_item = False
else:
- item = deku_scrubs_items[location.vanilla_item]
- if isinstance(item, list):
- item = random.choices([i[0] for i in item], weights=[i[1] for i in item], k=1)[0]
+ scrub_item = deku_scrubs_items[location.vanilla_item]
+ if isinstance(scrub_item, list):
+ item = random.choices([i[0] for i in scrub_item], weights=[i[1] for i in scrub_item], k=1)[0]
+ else:
+ item = scrub_item
shuffle_item = True
# Kokiri Sword
@@ -666,6 +670,7 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
elif location.vanilla_item in trade_items:
if not world.settings.adult_trade_shuffle:
if location.vanilla_item == 'Pocket Egg' and world.settings.adult_trade_start:
+ assert world.selected_adult_trade_item is not None
item = world.selected_adult_trade_item
shuffle_item = True
else:
@@ -735,7 +740,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:
@@ -806,6 +811,7 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
shuffle_item = True
else:
dungeon = Dungeon.from_vanilla_reward(ItemFactory(location.vanilla_item, world))
+ assert dungeon is not None
dungeon.reward.append(ItemFactory(item, world))
# Ganon boss key
@@ -982,7 +988,7 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]:
remove_junk_pool, _ = zip(*junk_pool_base)
# Omits Rupees (200) and Deku Nuts (10)
- remove_junk_pool = list(remove_junk_pool) + ['Recovery Heart', 'Bombs (20)', 'Arrows (30)', 'Ice Trap']
+ remove_junk_pool = *remove_junk_pool, 'Recovery Heart', 'Bombs (20)', 'Arrows (30)', 'Ice Trap'
junk_candidates = [item for item in pool if item in remove_junk_pool]
while pending_junk_pool:
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 fdca55f29..b28e00390 100644
--- a/Location.py
+++ b/Location.py
@@ -174,8 +174,12 @@ def LocationFactory(locations: str | list[str]) -> Location | list[Location]:
if location in location_table:
match_location = location
else:
- match_location = next(filter(lambda k: k.lower() == location.lower(), location_table), None)
- if match_location:
+ match_location = None
+ for k in location_table:
+ if k.lower() == location.lower():
+ match_location = k
+ break
+ 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 34635d50b..05085145f 100644
--- a/LocationList.py
+++ b/LocationList.py
@@ -59,8 +59,6 @@ def shop_address(shop_id: int, shelf_id: int) -> int:
# For cutscene/song/boss locations, the Scene is set to 0xFF. This matches the behavior of the push_delayed_item C function.
-# 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
@@ -2635,11 +2633,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)],
@@ -2647,8 +2644,12 @@ def shop_address(shop_id: int, shelf_id: int) -> int:
def location_is_viewable(loc_name: str, correct_chest_appearances: str, fast_chests: bool, *, world: Optional[World] = None) -> bool:
- return (
- ((correct_chest_appearances in ('textures', 'both', 'classic') or not fast_chests) and loc_name in location_groups['Chest'])
- or loc_name in location_groups['CanSee']
- or (world is not None and world.bigocto_location() is not None and world.bigocto_location().name == loc_name)
- )
+ if (correct_chest_appearances in ('textures', 'both', 'classic') or not fast_chests) and loc_name in location_groups['Chest']:
+ return True
+ if loc_name in location_groups['CanSee']:
+ return True
+ if world is not None:
+ bigocto_location = world.bigocto_location()
+ if bigocto_location is not None and bigocto_location.name == loc_name:
+ return True
+ return False
diff --git a/MQ.py b/MQ.py
index 7bc8d8d0e..75bd7676e 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
@@ -63,15 +70,18 @@ def __init__(self, name: str, start: int = 0, end: Optional[int] = None, remap:
self.from_file: int = self.start
# used to update the file's associated dmadata record
- self.dma_key: int = self.start
+ self.dma_key: Optional[int] = self.start
@classmethod
- def from_json(cls, file: dict[str, Optional[str]]) -> File:
+ def from_json(cls, file: JsonFile) -> File:
+ start = file.get('Start', None)
+ end = file.get('End', None)
+ remap_start = file.get('RemapStart', None)
return cls(
file['Name'],
- int(file['Start'], 16) if file.get('Start', None) is not None else 0,
- int(file['End'], 16) if file.get('End', None) is not None else None,
- int(file['RemapStart'], 16) if file.get('RemapStart', None) is not None else None
+ 0 if start is None else int(start, 16),
+ None if end is None else int(end, 16),
+ None if remap_start is None else int(remap_start, 16),
)
def __repr__(self) -> str:
@@ -113,16 +123,29 @@ def write_to_scene(self, rom: Rom, start: int) -> None:
rom.write_int32s(addr, [self.poly_addr, self.polytypes_addr, self.camera_data_addr])
+class JsonColDelta(TypedDict):
+ IsLarger: bool
+ Polys: list[dict[str, int]]
+ PolyTypes: list[dict[str, int]]
+ Cams: list[dict[str, int]]
+
+
class ColDelta:
- def __init__(self, delta: dict[str, bool | list[dict[str, int]]]) -> None:
+ def __init__(self, delta: JsonColDelta) -> None:
self.is_larger: bool = delta['IsLarger']
self.polys: list[dict[str, int]] = delta['Polys']
self.polytypes: list[dict[str, int]] = delta['PolyTypes']
self.cams: list[dict[str, int]] = delta['Cams']
+class JsonIcon(TypedDict):
+ Icon: int
+ Count: int
+ IconPoints: list[dict[str, int]]
+
+
class Icon:
- def __init__(self, data: dict[str, int | list[dict[str, int]]]) -> None:
+ def __init__(self, data: JsonIcon) -> None:
self.icon: int = data["Icon"]
self.count: int = data["Count"]
self.points: list[IconPoint] = [IconPoint(x) for x in data["IconPoints"]]
@@ -367,8 +390,15 @@ def append_path_data(self, rom: Rom) -> int:
return records_offset
+class JsonRoom(TypedDict):
+ File: JsonFile
+ Id: int
+ Objects: list[str]
+ Actors: list[str]
+
+
class Room:
- def __init__(self, room: dict[str, int | list[str] | dict[str, Optional[str]]]):
+ def __init__(self, room: JsonRoom) -> None:
self.file: File = File.from_json(room['File'])
self.id: int = room['Id']
self.objects: list[int] = [int(x, 16) for x in room['Objects']]
@@ -600,25 +630,25 @@ def insert_space(rom: Rom, file: File, vram_start: int, insert_section: int, ins
(offset + insert_size))
# value contains the vram address
- value = rom.read_int32(address)
+ base_value = rom.read_int32(address)
reg = None
if type == 2:
# Data entry: value is the raw vram address
- pass
+ value = base_value
elif type == 4:
# Jump OP: Get the address from a Jump instruction
- value = 0x80000000 | (value & 0x03FFFFFF) << 2
+ value = 0x80000000 | (base_value & 0x03FFFFFF) << 2
elif type == 5:
# Load High: Upper half of an address load
- reg = (value >> 16) & 0x1F
- val_hi[reg] = (value & 0x0000FFFF) << 16
+ reg = (base_value >> 16) & 0x1F
+ val_hi[reg] = (base_value & 0x0000FFFF) << 16
adr_hi[reg] = address
# Do not process, wait until the lower half is read
value = None
elif type == 6:
# Load Low: Lower half of the address load
- reg = (value >> 21) & 0x1F
- val_low = value & 0x0000FFFF
+ reg = (base_value >> 21) & 0x1F
+ val_low = base_value & 0x0000FFFF
val_low = unpack('h', pack('H', val_low))[0]
# combine with previous load high
value = val_hi[reg] + val_low
@@ -641,6 +671,8 @@ def insert_space(rom: Rom, file: File, vram_start: int, insert_section: int, ins
new_value = op | new_value
rom.write_int32(address, new_value)
elif type == 6:
+ assert reg is not None
+
# Load Low: Lower half of the address load
op = rom.read_int32(address) & 0xFFFF0000
new_val_low = new_value & 0x0000FFFF
diff --git a/Messages.py b/Messages.py
index b0edf59dc..62e976355 100644
--- a/Messages.py
+++ b/Messages.py
@@ -128,7 +128,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()
@@ -564,7 +564,7 @@
# convert byte array to an integer
-def bytes_to_int(data: bytes, signed: bool = False) -> int:
+def bytes_to_int(data: bytes | list[int], signed: bool = False) -> int:
return int.from_bytes(data, byteorder='big', signed=signed)
@@ -702,7 +702,7 @@ def write(self, rom: Rom, text_start: int, offset: int) -> int:
class Message:
def __init__(self, raw_text: list[int] | bytearray | str, index: int, id: int, opts: int, offset: int, length: int) -> None:
if isinstance(raw_text, str):
- raw_text = encode_text_string(raw_text)
+ raw_text = bytearray(encode_text_string(raw_text))
elif not isinstance(raw_text, bytearray):
raw_text = bytearray(raw_text)
@@ -929,7 +929,7 @@ def update_message_by_id(messages: list[Message], id: int, text: bytearray | str
if index >= 0:
update_message_by_index(messages, index, text, opts)
else:
- add_message(messages, text, id, opts)
+ add_message(messages, text, id, opts or 0x00)
# Gets the message by its ID. Returns None if the index does not exist
@@ -1007,7 +1007,7 @@ def display(self) -> str:
def write(self, rom: Rom, shop_table_address: int, index: int) -> None:
entry_offset = shop_table_address + 0x20 * index
- data = []
+ data: list[int] = []
data += int_to_bytes(self.object, 2)
data += int_to_bytes(self.model, 2)
data += int_to_bytes(self.func1, 4)
@@ -1143,14 +1143,14 @@ def make_player_message(text: str) -> str:
# make sure to call this AFTER move_shop_item_messages()
def update_item_messages(messages: list[Message], world: World) -> None:
new_item_messages = ITEM_MESSAGES + KEYSANITY_MESSAGES
- for id, text in new_item_messages:
+ for id, new_item_text in new_item_messages:
if world.settings.world_count > 1:
- update_message_by_id(messages, id, make_player_message(text), 0x23)
+ update_message_by_id(messages, id, make_player_message(new_item_text), 0x23)
else:
- update_message_by_id(messages, id, text, 0x23)
+ update_message_by_id(messages, id, new_item_text, 0x23)
- for id, (text, opt) in MISC_MESSAGES:
- update_message_by_id(messages, id, text, opt)
+ for id, (misc_text, opt) in MISC_MESSAGES:
+ update_message_by_id(messages, id, misc_text, opt)
# run all keysanity related patching to add messages for dungeon specific items
def add_item_messages(messages: list[Message], shop_items: Iterable[ShopItem], world: World) -> None:
@@ -1205,7 +1205,7 @@ def read_fffc_message(rom: Rom) -> Message:
# write the messages back
-def repack_messages(rom: Rom, messages: list[Message], permutation: Optional[list[int]] = None,
+def repack_messages(rom: Rom, messages: list[Message], permutation: Optional[Iterable[int]] = None,
always_allow_skip: bool = True, speed_up_text: bool = True) -> None:
rom.update_dmadata_record_by_key(ENG_TEXT_START, ENG_TEXT_START, ENG_TEXT_START + ENG_TEXT_SIZE_LIMIT)
rom.update_dmadata_record_by_key(JPN_TEXT_START, JPN_TEXT_START, JPN_TEXT_START + JPN_TEXT_SIZE_LIMIT)
@@ -1293,21 +1293,9 @@ def repack_messages(rom: Rom, messages: list[Message], permutation: Optional[lis
# shuffles the messages in the game, making sure to keep various message types in their own group
+SHOP_ITEM_MESSAGES: list[int] = []
+SCRUBS_MESSAGE_IDS: list[int] = []
def shuffle_messages(messages: list[Message], except_hints: bool = True) -> list[int]:
- if not hasattr(shuffle_messages, "shop_item_messages"):
- shuffle_messages.shop_item_messages = []
- if not hasattr(shuffle_messages, "scrubs_message_ids"):
- shuffle_messages.scrubs_message_ids = []
-
- hint_ids = (
- GOSSIP_STONE_MESSAGES + TEMPLE_HINTS_MESSAGES +
- [data['id'] for data in misc_item_hint_table.values()] +
- [data['id'] for data in misc_location_hint_table.values()] +
- [message_id for (message_id, message) in KEYSANITY_MESSAGES] + shuffle_messages.shop_item_messages +
- shuffle_messages.scrubs_message_ids +
- [0x5036, 0x70F5] # Chicken count and poe count respectively
- )
-
permutation = [i for i, _ in enumerate(messages)]
def is_exempt(m: Message) -> bool:
@@ -1315,9 +1303,8 @@ def is_exempt(m: Message) -> bool:
GOSSIP_STONE_MESSAGES + TEMPLE_HINTS_MESSAGES +
[data['id'] for data in misc_item_hint_table.values()] +
[data['id'] for data in misc_location_hint_table.values()] +
- [message_id for (message_id, message) in KEYSANITY_MESSAGES] +
- shuffle_messages.shop_item_messages +
- shuffle_messages.scrubs_message_ids +
+ [message_id for message_id, _ in KEYSANITY_MESSAGES] +
+ SHOP_ITEM_MESSAGES + SCRUBS_MESSAGE_IDS +
[0x5036, 0x70F5] # Chicken count and poe count respectively
)
shuffle_exempt = [
@@ -1325,9 +1312,9 @@ def is_exempt(m: Message) -> bool:
0x208D, # "One more lap!" for Cow in House race.
0xFFFC, # Character data from JP table used on title and file select screens
]
- is_hint = (except_hints and m.id in hint_ids)
- is_error_message = (m.id == ERROR_MESSAGE)
- is_shuffle_exempt = (m.id in shuffle_exempt)
+ is_hint = except_hints and m.id in hint_ids
+ is_error_message = m.id == ERROR_MESSAGE
+ is_shuffle_exempt = m.id in shuffle_exempt
return is_hint or is_error_message or m.is_id_message() or is_shuffle_exempt
have_goto = list(filter(lambda m: not is_exempt(m) and m.has_goto, messages))
@@ -1378,7 +1365,8 @@ def update_warp_song_text(messages: list[Message], world: World) -> None:
for id, entr in msg_list.items():
if 'warp_songs_and_owls' in world.settings.misc_hints or not world.settings.warp_songs:
destination = world.get_entrance(entr).connected_region
- destination_name = HintArea.at(destination)
+ assert destination is not None
+ destination_name: Any = HintArea.at(destination)
color = COLOR_MAP[destination_name.color]
if destination_name.preposition(True) is not None:
destination_name = f'to {destination_name}'
@@ -1393,6 +1381,7 @@ def update_warp_song_text(messages: list[Message], world: World) -> None:
for id, entr in owl_messages.items():
if 'warp_songs_and_owls' in world.settings.misc_hints:
destination = world.get_entrance(entr).connected_region
+ assert destination is not None
destination_name = HintArea.at(destination)
color = COLOR_MAP[destination_name.color]
if destination_name.preposition(True) is not None:
diff --git a/Models.py b/Models.py
index 4ac6f6486..3e02de39d 100644
--- a/Models.py
+++ b/Models.py
@@ -93,33 +93,35 @@ 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
+def scan(bytes_to_scan: bytearray, data: bytearray | str, start: int = 0) -> int:
+ 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)):
+ for i in range(start, len(bytes_to_scan)):
# Byte matches next byte in string
- if bytes[i] == databytes[dataindex]:
+ if bytes_to_scan[i] == databytes[dataindex]:
dataindex += 1
# Special case: Bottle, Bow, Slingshot, Fist.L, and Fist.R are subsets of
# Bottle.Hand.L, Bow.String, Slingshot.String, Gauntlet.Fist.L, and Gauntlet.Fist.R respectively
# And Hookshot which is a subset of Hookshot.Spike, Hookshot.Chain, Hookshot.Aiming.Reticule
# This leads to false positives. So if the next byte is . (0x2E) then reset the count.
- if isinstance(data, str) and data in ["Bottle", "Bow", "Slingshot", "Hookshot", "Fist.L", "Fist.R", "Blade.3"] and i < len(bytes) - 1 and bytes[i+1] == 0x2E:
+ if isinstance(data, str) and data in ["Bottle", "Bow", "Slingshot", "Hookshot", "Fist.L", "Fist.R", "Blade.3"] and i < len(bytes_to_scan) - 1 and bytes_to_scan[i+1] == 0x2E:
# Blade.3 is even wackier, as it is a subset of Blade.3.Break,
# and also a forward subset of Broken.Blade.3, and has a period in it
if data == "Blade.3":
resetCount = False
# If current byte is the "e" in "Blade.3", the period detected is the expected one- Carry on
# If it isn't, then reset the count
- if bytes[i] != 0x65:
+ if bytes_to_scan[i] != 0x65:
resetCount = True
# Make sure i is large enough, "Broken.Blad" is 11 chars (remember we're currently at the e)
if not resetCount and i > 10:
# Check if "Broken." immediately preceeds this string
- preceedingBytes = bytes[i-11:i-4]
+ preceedingBytes = bytes_to_scan[i-11:i-4]
if preceedingBytes == bytearray(b'Broken.'):
resetCount = True
if resetCount:
@@ -128,7 +130,7 @@ def scan(bytes: bytearray, data: bytearray | str, start: int = 0) -> int:
# "Gauntlet.Fis" is 12 chars (we are currently at the t)
elif data in ["Fist.L", "Fist.R"] and i > 11:
# Check if "Gauntlet." immediately preceeds this string
- preceedingBytes = bytes[i-12:i-3]
+ preceedingBytes = bytes_to_scan[i-12:i-3]
if preceedingBytes == bytearray(b'Gauntlet.'):
dataindex = 0
# Default case for Bottle, Bow, Slingshot, Hookshot, reset count
@@ -138,7 +140,7 @@ def scan(bytes: bytearray, data: bytearray | str, start: int = 0) -> int:
# (Blade.3 and fists can check in the previous stanza since a . will be encountered at some point)
if isinstance(data, str) and data == "Hookshot" and dataindex == 1 and i > 3:
# Check if "FPS." immediately preceeds this string
- preceedingBytes = bytes[i-4:i]
+ preceedingBytes = bytes_to_scan[i-4:i]
if preceedingBytes == bytearray(b'FPS.'):
dataindex = 0
# More special cases added by the new pipeline...
@@ -146,20 +148,20 @@ def scan(bytes: bytearray, data: bytearray | str, start: int = 0) -> int:
# And Hand.L specifically is a forward subset of Bottle.Hand.L
if isinstance(data, str) and data in ["Hand.L", "Hand.R"] and dataindex == 1:
if i > 8:
- preceedingBytes = bytes[i-9:i]
+ preceedingBytes = bytes_to_scan[i-9:i]
if preceedingBytes == bytearray(b'Gauntlet.'):
dataindex = 0
if dataindex == 1 and i > 3:
- preceedingBytes = bytes[i-4:i]
+ preceedingBytes = bytes_to_scan[i-4:i]
if preceedingBytes == bytearray(b'FPS.'):
dataindex = 0
if data == "Hand.L" and dataindex == 1 and i > 6:
- preceedingBytes = bytes[i-7:i]
+ preceedingBytes = bytes_to_scan[i-7:i]
if preceedingBytes == bytearray(b'Bottle.'):
dataindex = 0
# Forearm.L and Forearm.R are forward subsets of FPS.Forearm.X
if isinstance(data, str) and data in ["Forearm.L", "Forearm.R"] and dataindex == 1 and i > 3:
- preceedingBytes = bytes[i-4:i]
+ preceedingBytes = bytes_to_scan[i-4:i]
if preceedingBytes == bytearray(b'FPS.'):
dataindex = 0
# All bytes have been found, so a match
@@ -172,7 +174,7 @@ def scan(bytes: bytearray, data: bytearray | str, start: int = 0) -> int:
i += 2
offsetbytes = []
for j in range(4):
- offsetbytes.append(bytes[i + j])
+ offsetbytes.append(bytes_to_scan[i + j])
return int.from_bytes(offsetbytes, 'big')
# Match has been broken, reset to start of string
else:
@@ -209,9 +211,9 @@ def LoadVanilla(rom: Rom, missing: list[str], rebase: int, linkstart: int, links
for i in range(linksize):
vanillaData.append(rom.buffer[linkstart + i])
segment = 0x06
- vertices = {}
- matrices = {}
- textures = {}
+ vertices: dict[int, list[int]] = {}
+ matrices: dict[int, list[int]] = {}
+ textures: dict[int, list[int]] = {}
displayLists = {}
# For each missing piece, grab data from its vanilla display list
for item in missing:
@@ -283,7 +285,7 @@ def LoadVanilla(rom: Rom, missing: list[str], rebase: int, linkstart: int, links
# Grab the address from the low byte without the base offset
texOffset = lo & 0x00FFFFFF
numTexels = -1
- returnStack = []
+ returnStack: list[int] = []
j = i+8
# The point of this loop is just to find the number of texels
# so that it may be multiplied by the bytesPerTexel so we know
@@ -343,7 +345,7 @@ def LoadVanilla(rom: Rom, missing: list[str], rebase: int, linkstart: int, links
i += 8
displayLists[item] = (displayList, offset)
# Create vanilla zobj of the pieces from data collected during crawl
- vanillaZobj = []
+ vanillaZobj: list[int] = []
# Add textures, vertices, and matrices to the beginning of the zobj
# Textures
oldTex2New = {}
@@ -473,13 +475,13 @@ def CorrectSkeleton(zobj: bytearray, skeleton: list[list[int]], agestr: str) ->
hasVanillaSkeleton = True
for i in range(21):
offset = limb + i * 0x10
- bytes = []
- bytes.extend(int.to_bytes(skeleton[i][0], 2, 'big'))
- bytes.extend(int.to_bytes(skeleton[i][1], 2, 'big'))
- bytes.extend(int.to_bytes(skeleton[i][2], 2, 'big'))
+ skeleton_bytes: list[int] = []
+ skeleton_bytes.extend(int.to_bytes(skeleton[i][0], 2, 'big'))
+ skeleton_bytes.extend(int.to_bytes(skeleton[i][1], 2, 'big'))
+ skeleton_bytes.extend(int.to_bytes(skeleton[i][2], 2, 'big'))
# Overwrite the X, Y, Z bytes with their vanilla values
for j in range(6):
- zobj[offset+j] = bytes[j]
+ zobj[offset+j] = skeleton_bytes[j]
return hasVanillaSkeleton
@@ -530,7 +532,7 @@ def LoadModel(rom: Rom, model: str, age: int) -> int:
# Find which pieces, if any, are missing from this model
missing = []
present = {}
- DLOffsets = {}
+ DLOffsets: dict[str, int] = {}
for piece in pieces:
offset = scan(zobj, piece, footerstart)
if offset == -1:
diff --git a/Music.py b/Music.py
index 4d20d52b4..c2ae4b986 100644
--- a/Music.py
+++ b/Music.py
@@ -245,7 +245,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..6d4953cf4 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:
@@ -171,12 +171,11 @@ def create_patch_file(rom: Rom, file: str, xor_range: tuple[int, int] = (0x00B8A
xor_address = write_block(rom, xor_address, xor_range, block_start, data, patch_data)
# compress the patch file
- patch_data = bytes(patch_data.buffer)
- patch_data = zlib.compress(patch_data)
+ compressed_patch_data = zlib.compress(patch_data.buffer)
# save the patch file
with open(file, 'wb') as outfile:
- outfile.write(patch_data)
+ outfile.write(compressed_patch_data)
# This will apply a patch file to a source rom to generate a patched rom.
@@ -188,13 +187,13 @@ def apply_patch_file(rom: Rom, settings: Settings, sub_file: Optional[str] = Non
with zipfile.ZipFile(file, 'r') as patch_archive:
try:
with patch_archive.open(sub_file, 'r') as stream:
- patch_data = stream.read()
+ compressed_patch_data = stream.read()
except KeyError as ex:
raise FileNotFoundError('Patch file missing from archive. Invalid Player ID.')
else:
with open(file, 'rb') as stream:
- patch_data = stream.read()
- patch_data = BigStream(bytearray(zlib.decompress(patch_data)))
+ compressed_patch_data = stream.read()
+ patch_data = BigStream(bytearray(zlib.decompress(compressed_patch_data)))
# make sure the header is correct
if patch_data.read_bytes(length=4) != b'ZPFv':
diff --git a/OcarinaSongs.py b/OcarinaSongs.py
index 4b616b89b..eb7cd1d8e 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,8 @@
ActivationTransform: TypeAlias = "Callable[[list[int]], list[int]]"
PlaybackTransform: TypeAlias = "Callable[[list[dict[str, int]]], list[dict[str, int]]]"
-Transform: TypeAlias = "ActivationTransform | PlaybackTransform"
+P = TypeVar('P', "list[int]", "list[dict[str, int]]")
+T = TypeVar('T', ActivationTransform, PlaybackTransform)
PLAYBACK_START: int = 0xB781DC
PLAYBACK_LENGTH: int = 0xA0
@@ -137,7 +138,7 @@ def copy_playback_info(playback: list[dict[str, int]], piece: list[int]):
return [{'note': n, 'volume': p['volume'], 'duration': p['duration']} for (p, n) in zip(playback, piece)]
-def identity(x: list[int | dict[str, int]]) -> list[int | dict[str, int]]:
+def identity(x: P) -> P:
return x
@@ -149,7 +150,7 @@ def invert_piece(piece: list[int]) -> list[int]:
return [4 - note for note in piece]
-def reverse_piece(piece: list[int | dict[str, int]]) -> list[int | dict[str, int]]:
+def reverse_piece(piece: P) -> P:
return piece[::-1]
@@ -163,7 +164,7 @@ def transpose(piece: list[int]) -> list[int]:
return transpose
-def compose(f: Transform, g: Transform) -> Transform:
+def compose(f: T, g: T) -> T:
return lambda x: f(g(x))
@@ -188,6 +189,7 @@ def __init__(self, rand_song: bool = True, piece_size: int = 3, extra_position:
self.activation_data: list[int] = []
self.playback_data: list[int] = []
self.total_duration: int = 0
+ self.difficulty: int = -1
if activation:
self.length = len(activation)
@@ -341,8 +343,8 @@ def get_random_song() -> Song:
rand_song = random.choices([True, False], [1, 9])[0]
piece_size = random.choices([3, 4], [5, 2])[0]
extra_position = random.choices(['none', 'start', 'middle', 'end'], [12, 1, 1, 1])[0]
- activation_transform = identity
- playback_transform = identity
+ activation_transform: ActivationTransform = identity
+ playback_transform: PlaybackTransform = identity
weight_damage = 0
should_transpose = random.choices([True, False], [1, 4])[0]
starting_range = range(0, 5)
@@ -361,7 +363,7 @@ def get_random_song() -> Song:
activation_transform = compose(invert_piece, activation_transform)
should_reflect = random.choices([True, False], [5 - weight_damage, 4])[0]
if should_reflect:
- activation_transform = compose(reverse_piece, activation_transform)
+ activation_transform = compose(reverse_piece, activation_transform) # type: ignore #TODO mypy doesn't seem to understand that functions are callable?
playback_transform = reverse_piece
# print([rand_song, piece_size, extra_position, starting_range, should_transpose, should_invert, should_reflect])
@@ -403,7 +405,7 @@ def generate_song_list(world: World, frog: bool, warp: bool) -> dict[str, Song]:
for name2, song2 in fixed_songs.items():
if name1 != name2 and subsong(song1, song2):
raise ValueError(f'{name2} is unplayable because it contains {name1}')
- random_songs = []
+ random_songs: list[Song] = []
for _ in range(12 - len(fixed_songs)):
for _ in range(1000):
diff --git a/Patches.py b/Patches.py
index fad571bb5..eab2d3bf7 100644
--- a/Patches.py
+++ b/Patches.py
@@ -19,7 +19,8 @@
from ItemPool import reward_list, song_list, trade_items, child_trade_items
from Location import Location, DisableType
from LocationList import business_scrubs
-from Messages import read_messages, update_message_by_id, read_shop_items, update_warp_song_text, \
+import Messages
+from Messages import SCRUBS_MESSAGE_IDS, SHOP_ITEM_MESSAGES, read_messages, update_message_by_id, read_shop_items, update_warp_song_text, \
write_shop_items, remove_unused_messages, make_player_message, \
add_item_messages, repack_messages, shuffle_messages, \
get_message_by_id, TextCode, new_messages, COLOR_MAP
@@ -59,8 +60,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)])
@@ -98,8 +99,8 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> Rom:
extended_objects_start = start_address = rom.dma.free_space()
for name, zobj_path, object_id in zobj_imports:
- with open(zobj_path, 'rb') as stream:
- obj_data = stream.read()
+ with open(zobj_path, 'rb') as zobj_stream:
+ obj_data = zobj_stream.read()
rom.write_bytes(start_address, obj_data)
# Add it to the extended object table
end_address = ((start_address + len(obj_data) + 0x0F) >> 4) << 4
@@ -148,7 +149,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> Rom:
)
for name, start, end, offsets, object_id in zobj_splits:
obj_file = File(name, start, end)
- seen = {}
+ seen: dict[int, int] = {}
out = []
out_size = 0
for offset in offsets:
@@ -266,7 +267,7 @@ def copy(addr, size):
extended_textures_start = start_address = rom.dma.free_space()
for texture_id, texture_name, rom_address_base, rom_address_palette, size, func, patch_file in crate_textures:
# Apply the texture patch. Resulting texture will be stored in texture_data as a bytearray
- texture_data = func(rom, rom_address_base, rom_address_palette, size, data_path(patch_file) if patch_file else None)
+ texture_data = func(rom, rom_address_base, rom_address_palette, size, None if patch_file is None else data_path(patch_file))
rom.write_bytes(start_address, texture_data) # write the bytes to our new file
end_address = ((start_address + len(texture_data) + 0x0F) >> 4) << 4
@@ -703,40 +704,45 @@ def set_entrance_updates(entrances: Iterable[Entrance]) -> None:
continue
new_entrance = entrance.data
replaced_entrance = (entrance.replaces or entrance).data
+ assert replaced_entrance is not None
# Fixup save/quit and death warping entrance IDs on bosses.
if 'savewarp_addresses' in replaced_entrance and entrance.reverse:
- if entrance.parent_region.savewarp:
- savewarp = entrance.parent_region.savewarp.replaces.data['index']
- elif 'savewarp_fallback' in entrance.reverse.data:
+ if entrance.parent_region is not None and entrance.parent_region.savewarp is not None:
+ assert entrance.parent_region.savewarp.replaces is not None
+ assert entrance.parent_region.savewarp.replaces.data is not None
+ savewarp_index = entrance.parent_region.savewarp.replaces.data['index']
+ elif entrance.reverse.data is not None and 'savewarp_fallback' in entrance.reverse.data:
# Spawning outside a grotto crashes the game, so we use a nearby regular entrance instead.
if entrance.reverse.data['savewarp_fallback'] == 0x0117:
# We don't want savewarping in a boss room inside GV Octorok Grotto to allow out-of-logic access to Gerudo Valley,
# so we spawn the player at whatever entrance GV Lower Stream -> Lake Hylia leads to.
- savewarp = world.get_entrance('GV Lower Stream -> Lake Hylia')
- savewarp = (savewarp.replaces or savewarp).data
- if 'savewarp_fallback' in savewarp:
+ savewarp_entrance = world.get_entrance('GV Lower Stream -> Lake Hylia')
+ savewarp_data = (savewarp_entrance.replaces or savewarp_entrance).data
+ if 'savewarp_fallback' in savewarp_data:
# the entrance GV Lower Stream -> Lake Hylia leads to is also not a valid savewarp so we place the player at Gerudo Valley from Hyrule Field instead
- savewarp = entrance.reverse.data['savewarp_fallback']
+ savewarp_index = entrance.reverse.data['savewarp_fallback']
else:
- savewarp = savewarp['index']
+ savewarp_index = savewarp_data['index']
else:
- savewarp = entrance.reverse.data['savewarp_fallback']
+ savewarp_index = entrance.reverse.data['savewarp_fallback']
else:
# Spawning inside a grotto also crashes, but exiting a grotto can currently only lead to a boss room in decoupled,
# so we follow the entrance chain back to the nearest non-grotto.
- savewarp = entrance
- while 'savewarp_fallback' in savewarp.data:
- parents = list(filter(lambda parent: parent.reverse, savewarp.parent_region.entrances))
+ savewarp_entrance = entrance
+ while 'savewarp_fallback' in savewarp_entrance.data:
+ assert savewarp_entrance.parent_region is not None
+ parents = list(filter(lambda parent: parent.reverse, savewarp_entrance.parent_region.entrances))
if len(parents) == 0:
raise Exception('Unable to set savewarp')
elif len(parents) == 1:
- savewarp = parents[0]
+ savewarp_entrance = parents[0]
else:
raise Exception('Found grotto with multiple entrances')
- savewarp = savewarp.reverse.data['index']
+ assert savewarp_entrance.reverse is not None
+ savewarp_index = savewarp_entrance.reverse.data['index']
for address in replaced_entrance['savewarp_addresses']:
- rom.write_int16(address, savewarp)
+ rom.write_int16(address, savewarp_index)
for address in new_entrance.get('addresses', []):
rom.write_int16(address, replaced_entrance.get('child_index', replaced_entrance['index']))
@@ -1201,14 +1207,14 @@ def calculate_traded_flags(world):
# start with maps/compasses
if world.settings.shuffle_mapcompass == 'startwith':
- for dungeon in ('deku', 'dodongo', 'jabu', 'forest', 'fire', 'water', 'spirit', 'shadow', 'botw', 'ice'):
- save_context.addresses['dungeon_items'][dungeon]['compass'].value = True
- save_context.addresses['dungeon_items'][dungeon]['map'].value = True
+ for dungeon_name in ('deku', 'dodongo', 'jabu', 'forest', 'fire', 'water', 'spirit', 'shadow', 'botw', 'ice'):
+ save_context.addresses['dungeon_items'][dungeon_name]['compass'].value = True
+ save_context.addresses['dungeon_items'][dungeon_name]['map'].value = True
# start with silver rupees
if world.settings.shuffle_silver_rupees == 'remove':
for puzzle in world.silver_rupee_puzzles():
- save_context.give_item(world, f'Silver Rupee ({puzzle})', float('inf'))
+ save_context.give_item(world, f'Silver Rupee ({puzzle})', 10)
if world.settings.shuffle_smallkeys == 'vanilla':
if world.dungeon_mq['Spirit Temple']:
@@ -1305,7 +1311,7 @@ def calculate_traded_flags(world):
insert_space(rom, shop_item_file, shop_item_vram_start, 1, 0x3C + (0x20 * 50), 0x20 * 50)
# Add relocation entries for shop item table
- new_relocations = []
+ new_relocations: list[int | tuple[int, int]] = []
for i in range(50, 100):
new_relocations.append(shop_item_file.start + 0x1DEC + (i * 0x20) + 0x04)
new_relocations.append(shop_item_file.start + 0x1DEC + (i * 0x20) + 0x14)
@@ -1371,7 +1377,6 @@ def calculate_traded_flags(world):
# Update Child Anju's dialogue
new_message = "\x08What should I do!?\x01My \x05\x41Cuccos\x05\x40 have all flown away!\x04You, little boy, please!\x01Please gather at least \x05\x41%d Cuccos\x05\x40\x01for me.\x02" % world.settings.chicken_count
update_message_by_id(messages, 0x5036, new_message)
-
# Update "Princess Ruto got the Spiritual Stone!" text before the midboss in Jabu
location = world.bigocto_location()
if location is None or location.item is None or location.item.name == 'Nothing':
@@ -1389,7 +1394,9 @@ def calculate_traded_flags(world):
# Set Dungeon Reward Actor in Jabu Jabu to be accurate
if location is not None and location.item is not None: # TODO make actor invisible if no item?
- scene, type, default, _, _, _ = get_override_entry(location)
+ override_entry = get_override_entry(location)
+ assert override_entry is not None
+ scene, type, default, _, _, _ = override_entry
rom.write_bytes(rom.sym('CFG_BIGOCTO_OVERRIDE_KEY'), override_key_struct.pack(scene, type, default))
# use faster jabu elevator
@@ -1441,15 +1448,6 @@ def calculate_traded_flags(world):
if any(hint_type in world.settings.misc_hints for hint_type in ('10_skulltulas', '20_skulltulas', '30_skulltulas', '40_skulltulas', '50_skulltulas')):
rom.write_int16(0xEA185A, 0x44C8)
- # 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)
-
if world.shuffle_silver_rupees:
rom.write_byte(rom.sym('SHUFFLE_SILVER_RUPEES'), 1)
if world.settings.shuffle_silver_rupees != 'remove':
@@ -1539,10 +1537,12 @@ def calculate_traded_flags(world):
for location in world.get_filled_locations():
if location.type == 'Song' and not songs_as_items:
item = location.item
+ assert item is not None
special = item.special
locationaddress = location.address
secondaryaddress = location.address2
-
+ assert not isinstance(locationaddress, list)
+ assert not isinstance(secondaryaddress, list)
bit_mask_pointer = 0x8C34 + ((special['item_id'] - 0x65) * 4)
rom.write_byte(locationaddress, special['song_id'])
next_song_id = special['song_id'] + 0x0D
@@ -1606,7 +1606,7 @@ def calculate_traded_flags(world):
shop_items[0x000A].description_message = 0x80B5
shop_items[0x000A].purchase_message = 0x80BE
- shuffle_messages.shop_item_messages = []
+ Messages.SHOP_ITEM_MESSAGES = []
# kokiri shop
shop_locations = [location for location in world.get_region('KF Kokiri Shop').locations if location.type == 'Shop'] # Need to filter because of the freestanding item in KF Shop
@@ -1715,7 +1715,9 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
for (scrub_item, default_price, text_id, text_replacement) in business_scrubs:
if scrub_item not in single_item_scrubs.keys():
continue
- scrub_message_dict[text_id] = update_scrub_text(get_message_by_id(messages, text_id).raw_text, text_replacement, default_price, default_price)
+ message = get_message_by_id(messages, text_id)
+ assert message is not None
+ scrub_message_dict[text_id] = update_scrub_text(message.raw_text, text_replacement, default_price, default_price)
else:
# Rebuild Business Scrub Item Table
rom.seek_address(0xDF8684)
@@ -1727,22 +1729,25 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
rom.write_int32(None, 0x80A74FF8) # Can_Buy_Func
rom.write_int32(None, 0x80A75354) # Buy_Func
- scrub_message_dict[text_id] = update_scrub_text(get_message_by_id(messages, text_id).raw_text, text_replacement, default_price, price)
+ message = get_message_by_id(messages, text_id)
+ assert message is not None
+ scrub_message_dict[text_id] = update_scrub_text(message.raw_text, text_replacement, default_price, price)
# update actor IDs
set_deku_salesman_data(rom)
# Update scrub messages.
- shuffle_messages.scrubs_message_ids = []
- for text_id, message in scrub_message_dict.items():
- update_message_by_id(messages, text_id, message)
+ Messages.SCRUBS_MESSAGE_IDS = []
+ for text_id, text in scrub_message_dict.items():
+ update_message_by_id(messages, text_id, text)
if world.settings.shuffle_scrubs == 'random':
- shuffle_messages.scrubs_message_ids.append(text_id)
+ Messages.SCRUBS_MESSAGE_IDS.append(text_id)
if world.settings.shuffle_grotto_entrances:
# Build the Grotto Load Table based on grotto entrance data
for entrance in world.get_shuffled_entrances(type='Grotto'):
if entrance.primary:
+ assert entrance.data is not None
load_table_pointer = rom.sym('GROTTO_LOAD_TABLE') + 4 * entrance.data['grotto_id']
rom.write_int16(load_table_pointer, entrance.data['entrance'])
rom.write_byte(load_table_pointer + 2, entrance.data['content'])
@@ -1764,6 +1769,7 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
update_message_by_id(messages, 0x405E, "\x1AChomp chomp chomp...\x01We have... \x05\x41a mysterious item\x05\x40! \x01Do you want it...huh? Huh?\x04\x05\x41\x0860 Rupees\x05\x40 and it's yours!\x01Keyahahah!\x01\x1B\x05\x42Yes\x01No\x05\x40\x02")
else:
location = world.get_location("ZR Magic Bean Salesman")
+ assert location.item is not None
item_text = get_hint(get_item_generic_name(location.item), True).text
wrapped_item_text = line_wrap(item_text, False, False, False)
if wrapped_item_text != item_text:
@@ -1782,6 +1788,7 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
update_message_by_id(messages, 0x6077, "\x06\x41Well Come!\x04I am selling stuff, strange and \x01rare, from all over the world to \x01everybody.\x01Today's special is...\x04A mysterious item! \x01Intriguing! \x01I won't tell you what it is until \x01I see the money....\x04How about \x05\x41200 Rupees\x05\x40?\x01\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02")
else:
location = world.get_location("Wasteland Bombchu Salesman")
+ assert location.item is not None
item_text = get_hint(get_item_generic_name(location.item), True).text
wrapped_item_text = line_wrap(item_text, False, False, False)
if wrapped_item_text != item_text:
@@ -1798,6 +1805,7 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
update_message_by_id(messages, 0x304F, "How about buying this cool item for \x01200 Rupees?\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02")
else:
location = world.get_location("GC Medigoron")
+ assert location.item is not None
item_text = get_hint(get_item_generic_name(location.item), True).text
wrapped_item_text = line_wrap(item_text, False, False, False)
if wrapped_item_text != item_text:
@@ -1810,6 +1818,7 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
update_message_by_id(messages, 0x500C, "Mysterious item! How about\x01\x05\x41100 Rupees\x05\x40?\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02")
else:
location = world.get_location("Kak Granny Buy Blue Potion")
+ assert location.item is not None
item_text = get_hint(get_item_generic_name(location.item), True).text
wrapped_item_text = line_wrap(item_text, False, False, False)
if wrapped_item_text != item_text:
@@ -1829,6 +1838,7 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
update_message_by_id(messages, 0x6D, "I seem to have misplaced my\x01keys, but I have a fun item to\x01sell instead.\x04How about \x05\x4110 Rupees\x05\x40?\x01\x01\x1B\x05\x42Buy\x01Don't Buy\x05\x40\x02")
else:
location = world.get_location("Market Treasure Chest Game Salesman")
+ assert location.item is not None
item_text = get_hint(get_item_generic_name(location.item), True).text
wrapped_item_text = line_wrap(item_text, False, False, False)
if wrapped_item_text != item_text:
@@ -1859,43 +1869,43 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
HEART_CHEST_BIG = 17
if world.settings.shuffle_tcgkeys == 'vanilla':
# Force key chests in Treasure Chest Game to use the default chest texture when not shuffled
- item = read_rom_item(rom, 0x0071)
- item['chest_type'] = BROWN_CHEST
- write_rom_item(rom, 0x0071, item)
+ rom_item = read_rom_item(rom, 0x0071)
+ rom_item['chest_type'] = BROWN_CHEST
+ write_rom_item(rom, 0x0071, rom_item)
if world.settings.free_bombchu_drops or 'bombchus' in world.settings.minor_items_as_major_chest:
bombchu_ids = [0x006A, 0x0003, 0x006B]
for i in bombchu_ids:
- item = read_rom_item(rom, i)
- item['chest_type'] = GILDED_CHEST
- write_rom_item(rom, i, item)
+ rom_item = read_rom_item(rom, i)
+ rom_item['chest_type'] = GILDED_CHEST
+ write_rom_item(rom, i, rom_item)
if world.settings.bridge == 'tokens' or world.settings.lacs_condition == 'tokens' or world.settings.shuffle_ganon_bosskey == 'tokens':
- item = read_rom_item(rom, 0x005B)
- item['chest_type'] = SKULL_CHEST_BIG
- write_rom_item(rom, 0x005B, item)
+ rom_item = read_rom_item(rom, 0x005B)
+ rom_item['chest_type'] = SKULL_CHEST_BIG
+ write_rom_item(rom, 0x005B, rom_item)
if world.settings.bridge == 'hearts' or world.settings.lacs_condition == 'hearts' or world.settings.shuffle_ganon_bosskey == 'hearts':
heart_ids = [0x003D, 0x003E, 0x0076]
for i in heart_ids:
- item = read_rom_item(rom, i)
- item['chest_type'] = HEART_CHEST_BIG
- write_rom_item(rom, i, item)
+ rom_item = read_rom_item(rom, i)
+ rom_item['chest_type'] = HEART_CHEST_BIG
+ write_rom_item(rom, i, rom_item)
if 'shields' in world.settings.minor_items_as_major_chest:
# Deku
- item = read_rom_item(rom, 0x0029)
- item['chest_type'] = GILDED_CHEST
- write_rom_item(rom, 0x0029, item)
+ rom_item = read_rom_item(rom, 0x0029)
+ rom_item['chest_type'] = GILDED_CHEST
+ write_rom_item(rom, 0x0029, rom_item)
# Hylian
- item = read_rom_item(rom, 0x002A)
- item['chest_type'] = GILDED_CHEST
- write_rom_item(rom, 0x002A, item)
+ rom_item = read_rom_item(rom, 0x002A)
+ rom_item['chest_type'] = GILDED_CHEST
+ write_rom_item(rom, 0x002A, rom_item)
if 'capacity' in world.settings.minor_items_as_major_chest:
# Nuts
- item = read_rom_item(rom, 0x0087)
- item['chest_type'] = GILDED_CHEST
- write_rom_item(rom, 0x0087, item)
+ rom_item = read_rom_item(rom, 0x0087)
+ rom_item['chest_type'] = GILDED_CHEST
+ write_rom_item(rom, 0x0087, rom_item)
# Sticks
- item = read_rom_item(rom, 0x0088)
- item['chest_type'] = GILDED_CHEST
- write_rom_item(rom, 0x0088, item)
+ rom_item = read_rom_item(rom, 0x0088)
+ rom_item['chest_type'] = GILDED_CHEST
+ write_rom_item(rom, 0x0088, rom_item)
# Update chest type appearance
if world.settings.correct_chest_appearances == 'textures':
@@ -1922,8 +1932,11 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
if not world.dungeon_mq['Ganons Castle']:
chest_name = 'Ganons Castle Light Trial Lullaby Chest'
location = world.get_location(chest_name)
- item = read_rom_item(rom, (location.item.looks_like_item or location.item).index)
- if item['chest_type'] in (GOLD_CHEST, GILDED_CHEST, SKULL_CHEST_BIG, HEART_CHEST_BIG):
+ assert location.item is not None
+ item_index = (location.item.looks_like_item or location.item).index
+ assert item_index is not None
+ rom_item = read_rom_item(rom, item_index)
+ if rom_item['chest_type'] in (GOLD_CHEST, GILDED_CHEST, SKULL_CHEST_BIG, HEART_CHEST_BIG):
rom.write_int16(0x321B176, 0xFC40) # original 0xFC48
# Move Spirit Temple Compass Chest if it is a small chest so it is reachable with hookshot
@@ -1931,8 +1944,11 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
chest_name = 'Spirit Temple Compass Chest'
chest_address = 0x2B6B07C
location = world.get_location(chest_name)
- item = read_rom_item(rom, (location.item.looks_like_item or location.item).index)
- if item['chest_type'] in (BROWN_CHEST, SILVER_CHEST, SKULL_CHEST_SMALL, HEART_CHEST_SMALL):
+ assert location.item is not None
+ item_index = (location.item.looks_like_item or location.item).index
+ assert item_index is not None
+ rom_item = read_rom_item(rom, item_index)
+ if rom_item['chest_type'] in (BROWN_CHEST, SILVER_CHEST, SKULL_CHEST_SMALL, HEART_CHEST_SMALL):
rom.write_int16(chest_address + 2, 0x0190) # X pos
rom.write_int16(chest_address + 6, 0xFABC) # Z pos
@@ -1942,8 +1958,11 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
chest_address_0 = 0x21A02D0 # Address in setup 0
chest_address_2 = 0x21A06E4 # Address in setup 2
location = world.get_location(chest_name)
- item = read_rom_item(rom, (location.item.looks_like_item or location.item).index)
- if item['chest_type'] in (BROWN_CHEST, SILVER_CHEST, SKULL_CHEST_SMALL, HEART_CHEST_SMALL):
+ assert location.item is not None
+ item_index = (location.item.looks_like_item or location.item).index
+ assert item_index is not None
+ rom_item = read_rom_item(rom, item_index)
+ if rom_item['chest_type'] in (BROWN_CHEST, SILVER_CHEST, SKULL_CHEST_SMALL, HEART_CHEST_SMALL):
rom.write_int16(chest_address_0 + 6, 0x0172) # Z pos
rom.write_int16(chest_address_2 + 6, 0x0172) # Z pos
@@ -1997,20 +2016,25 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
update_message_by_id(messages, map_id, map_message, allow_duplicates=True)
else:
dungeon_name, compass_id, map_id = dungeon_list[dungeon.name]
+ assert dungeon.vanilla_boss_name is not None
if world.entrance_rando_reward_hints:
vanilla_reward = world.get_location(dungeon.vanilla_boss_name).vanilla_item
+ assert vanilla_reward is not None
vanilla_reward_location = world.hinted_dungeon_reward_locations[vanilla_reward]
if vanilla_reward_location is None:
area = HintArea.ROOT
else:
area = HintArea.at(vanilla_reward_location)
- area = GossipText(area.text(world.settings.clearer_hints, preposition=True, use_2nd_person=True), [area.color], prefix='', capitalize=False)
- compass_message = f"\x13\x75\x08You found the \x05\x41Compass\x05\x40\x01for {dungeon_name}\x05\x40!\x01The {vanilla_reward} can be found\x01{area}!\x09"
+ area_text = GossipText(area.text(world.settings.clearer_hints, preposition=True, use_2nd_person=True), [area.color], prefix='', capitalize=False)
+ compass_message = f"\x13\x75\x08You found the \x05\x41Compass\x05\x40\x01for {dungeon_name}\x05\x40!\x01The {vanilla_reward} can be found\x01{area_text}!\x09"
else:
if world.settings.logic_rules == 'glitched':
boss_location = world.get_location(dungeon.vanilla_boss_name)
else:
- boss_location = next(filter(lambda loc: loc.type == 'Boss', world.get_entrance(f'{dungeon} Before Boss -> {dungeon.vanilla_boss_name} Boss Room').connected_region.locations))
+ entrance = world.get_entrance(f'{dungeon} Before Boss -> {dungeon.vanilla_boss_name} Boss Room')
+ assert entrance.connected_region is not None
+ boss_location = next(filter(lambda loc: loc.type == 'Boss', entrance.connected_region.locations))
+ assert boss_location.item is not None
dungeon_reward = boss_location.item.name
compass_message = f"\x13\x75\x08You found the \x05\x41Compass\x05\x40\x01for {dungeon_name}\x05\x40!\x01It holds the \x05{COLOR_MAP[REWARD_COLORS[dungeon_reward]]}{dungeon_reward}\x05\x40!\x09"
if world.settings.shuffle_dungeon_rewards != 'dungeon':
@@ -2053,7 +2077,9 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
for message_id in (0x706F, 0x7091, 0x7092, 0x7093, 0x7094, 0x7095):
text_codes = []
chars_in_section = 1
- for code in get_message_by_id(messages, message_id).text_codes:
+ message = get_message_by_id(messages, message_id)
+ assert message is not None
+ for code in message.text_codes:
if code.code == 0x04: # box-break
text_codes.append(TextCode(0x0c, 80 + chars_in_section))
chars_in_section = 1
@@ -2089,8 +2115,8 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
bfa_message = make_player_message(bfa_message)
update_message_by_id(messages, 0x0071, bfa_message, 0x23, allow_duplicates=True)
- with open(data_path('blue_fire_arrow_item_name_eng.ia4'), 'rb') as stream:
- bfa_name_bytes = stream.read()
+ with open(data_path('blue_fire_arrow_item_name_eng.ia4'), 'rb') as bin_stream:
+ bfa_name_bytes = bin_stream.read()
rom.write_bytes(0x8a1c00, bfa_name_bytes)
repack_messages(rom, messages, permutation)
@@ -2247,6 +2273,7 @@ def update_scrub_text(message: bytearray, text_replacement: list[str], default_p
# Write numeric seed truncated to 32 bits for rng seeding
# Overwritten with new seed every time a new rng value is generated
+ assert spoiler.settings.numeric_seed is not None
rom.write_int32(rom.sym('RNG_SEED_INT'), spoiler.settings.numeric_seed & 0xFFFFFFFF)
# Static initial seed value for one-time random actions like the Hylian Shield discount
rom.write_int32(rom.sym('RANDOMIZER_RNG_SEED'), spoiler.settings.numeric_seed & 0xFFFFFFFF)
@@ -2317,31 +2344,36 @@ def get_override_table_bytes(override_table):
def get_override_entry(location: Location) -> Optional[OverrideEntry]:
scene = location.scene
default = location.default
+ assert location.item is not None
+ assert location.world is not None
item_id = location.item.index
- if None in (scene, default, item_id):
+ if scene is None or default is None or item_id is None:
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
+ assert location.item.world is not None
player_id = location.item.world.id + 1
if location.item.looks_like_item is not None:
+ assert location.item.looks_like_item.index is not None
looks_like_item_id = location.item.looks_like_item.index
else:
looks_like_item_id = 0
if location.type in ('NPC', 'Scrub', 'BossHeart'):
type = 0
+ assert isinstance(default, int)
elif location.type == 'Chest':
type = 1
+ assert isinstance(default, int)
default &= 0x1F
elif location.type in ('Freestanding', 'Pot', 'Crate', 'FlyingPot', 'SmallCrate', 'RupeeTower', 'Beehive', 'SilverRupee', 'Wonderitem'):
type = 6
- if not (isinstance(location.default, list) or isinstance(location.default, tuple)):
- raise Exception("Not right")
- if isinstance(location.default, list):
- default = location.default[0]
+ assert isinstance(default, list) or isinstance(default, tuple)
+ if isinstance(default, list):
+ default = default[0]
if len(default) == 3:
room, scene_setup, flag = default
@@ -2353,18 +2385,24 @@ def get_override_entry(location: Location) -> Optional[OverrideEntry]:
default = ((scene_setup & 0x1F) << 19) + ((room & 0x0F) << 15) + ((flag & 0x7F) << 8) + ((subflag & 0xFF)) #scene_setup = grotto_id
else:
default = (scene_setup << 22) + (room << 16) + (flag << 8) + (subflag)
- elif location.type in ('Collectable', 'ActorOverride'):
+ elif location.type == 'Collectable':
type = 2
+ assert isinstance(default, int)
elif location.type == 'GS Token':
type = 3
+ assert isinstance(default, int)
elif location.type == 'Shop' and location.item.type != 'Shop':
type = 0
+ assert isinstance(default, int)
elif location.type == 'MaskShop' and location.vanilla_item in location.world.settings.shuffle_child_trade:
type = 0
+ assert isinstance(default, int)
elif location.type == 'GrottoScrub' and location.item.type != 'Shop':
type = 4
+ assert isinstance(default, int)
elif location.type in ('Song', 'Cutscene', 'Boss'):
type = 5
+ assert isinstance(default, int)
else:
return None
@@ -2495,10 +2533,14 @@ def remove_entrance_blockers_do(rom: Rom, actor_id: int, actor: int, scene: int)
def set_cow_id_data(rom: Rom, world: World) -> None:
+ last_actor = -1
+ last_scene = -1
+ cow_count = 1
+
def set_cow_id(rom: Rom, actor_id: int, actor: int, scene: int) -> None:
+ nonlocal last_actor
nonlocal last_scene
nonlocal cow_count
- nonlocal last_actor
if actor_id == 0x01C6: # Cow
if scene == last_scene and last_actor != actor:
@@ -2513,10 +2555,6 @@ def set_cow_id(rom: Rom, actor_id: int, actor: int, scene: int) -> None:
else:
rom.write_int16(actor + 0x8, cow_count)
- last_actor = -1
- last_scene = -1
- cow_count = 1
-
get_actor_list(rom, set_cow_id)
@@ -2534,6 +2572,7 @@ def override_grotto_data(rom: Rom, actor_id: int, actor: int, scene: int) -> Non
# Build the override table based on shuffled grotto entrances
grotto_entrances_override = {}
for entrance in world.get_shuffled_entrances(type='Grotto'):
+ assert entrance.replaces is not None
if entrance.primary:
grotto_actor_id = (entrance.data['scene'] << 8) + entrance.data['content']
grotto_entrances_override[grotto_actor_id] = entrance.replaces.data['index']
@@ -2595,7 +2634,7 @@ def move_fado(rom, actor_id, actor, scene):
# If ganons boss key is set to remove, returns ganons boss key doors
# If pot/crate shuffle is enabled, returns the first ganon's boss key door so that it can be unlocked separately to allow access to the room w/ the pots..
def get_doors_to_unlock(rom: Rom, world: World) -> dict[int, list[int]]:
- def get_door_to_unlock(rom: Rom, actor_id: int, actor: int, scene: int) -> list[int]:
+ def get_door_to_unlock(rom: Rom, actor_id: int, actor: int, scene: int) -> Optional[list[int]]:
actor_var = rom.read_int16(actor + 14)
door_type = actor_var >> 6
switch_flag = actor_var & 0x003F
@@ -2632,6 +2671,7 @@ def get_door_to_unlock(rom: Rom, actor_id: int, actor: int, scene: int) -> list[
setting = world.settings.shuffle_bosskeys
if setting == 'remove' or (world.settings.shuffle_pots and scene == 0x0A and switch_flag == 0x15):
return [0x00D4 + scene * 0x1C + 0x04 + flag_byte, flag_bits]
+ return None
return get_actor_list(rom, get_door_to_unlock)
@@ -2654,9 +2694,12 @@ def create_fake_name(name: str) -> str:
return new_name
+SHOP_ID: int = 0x32
def place_shop_items(rom: Rom, world: World, shop_items, messages, locations, init_shop_id: bool = False) -> set[int]:
+ global SHOP_ID
+
if init_shop_id:
- place_shop_items.shop_id = 0x32
+ SHOP_ID = 0x32
shop_objs = {0x0148} # "Sold Out" object
for location in locations:
@@ -2682,9 +2725,8 @@ def place_shop_items(rom: Rom, world: World, shop_items, messages, locations, in
shop_objs.add(rom_item['object_id'])
- shop_id = place_shop_items.shop_id
- rom.write_int16(location.address, shop_id)
- shop_item = shop_items[shop_id]
+ rom.write_int16(location.address, SHOP_ID)
+ shop_item = shop_items[SHOP_ID]
shop_item.object = rom_item['object_id']
shop_item.model = rom_item['graphic_id'] - 1
@@ -2710,11 +2752,11 @@ def place_shop_items(rom: Rom, world: World, shop_items, messages, locations, in
('mask_shop' in world.settings.misc_hints and location.vanilla_item == 'Gerudo Mask' and 'Gerudo Mask' in world.settings.shuffle_child_trade))):
shop_item.func2 = 0x80863714 # override to custom CanBuy function to prevent purchase before trade quest complete
- message_id = (shop_id - 0x32) * 2
+ message_id = (SHOP_ID - 0x32) * 2
shop_item.description_message = 0x8100 + message_id
shop_item.purchase_message = 0x8100 + message_id + 1
- shuffle_messages.shop_item_messages.extend(
+ Messages.SHOP_ITEM_MESSAGES.extend(
[shop_item.description_message, shop_item.purchase_message])
if item_display.dungeonitem:
@@ -2756,7 +2798,7 @@ def place_shop_items(rom: Rom, world: World, shop_items, messages, locations, in
update_message_by_id(messages, shop_item.description_message, description_text, 0x03)
update_message_by_id(messages, shop_item.purchase_message, purchase_text, 0x03)
- place_shop_items.shop_id += 1
+ SHOP_ID += 1
return shop_objs
@@ -2786,11 +2828,15 @@ def configure_dungeon_info(rom: Rom, world: World) -> None:
location = world.hinted_dungeon_reward_locations[reward]
if location is None:
area = HintArea.ROOT
+ world_id = world.id
else:
+ assert location.world is not None
area = HintArea.at(location)
+ world_id = location.world.id
dungeon_reward_areas += area.short_name.encode('ascii').ljust(0x16) + b'\0'
- dungeon_reward_worlds.append((world.id if location is None else location.world.id) + 1)
- if location is not None and location.world.id == world.id and area.is_dungeon:
+ dungeon_reward_worlds.append(world_id + 1)
+ if location is not None and world_id == world.id and area.dungeon_name is not None:
+ assert location.item 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]
@@ -2811,31 +2857,6 @@ def configure_dungeon_info(rom: Rom, world: World) -> None:
rom.write_bytes(rom.sym('CFG_DUNGEON_PRECOMPLETED'), dungeon_precompleted)
-# 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:
- if isinstance(location.default, tuple):
- room, scene_setup, flag = location.default
- elif isinstance(location.default, list):
- room, scene_setup, flag = location.default[0]
- else:
- raise Exception(f"Location does not have compatible data for patch_rupee_tower: {location.name}")
-
- flag = flag | (room << 8) | (scene_setup << 14)
- if location.address:
- for address in location.address:
- rom.write_bytes(address + 12, flag.to_bytes(2, byteorder='big'))
-
-
# Patch the first boss key door in ganons tower that leads to the room w/ the pots
def patch_ganons_tower_bk_door(rom: Rom, flag: int) -> None:
var = (0x05 << 6) + (flag & 0x3F)
diff --git a/Plandomizer.py b/Plandomizer.py
index 307b5bd54..fc5bde887 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:
@@ -152,6 +152,8 @@ class LocationRecord(Record):
def __init__(self, src_dict: dict[str, Any] | str) -> None:
self.item: Optional[str | list[str]] = None
self.player: Optional[int] = None
+ self.model: Optional[str] = None
+ self.price: Optional[int] = None
if isinstance(src_dict, str):
src_dict = {'item': src_dict}
diff --git a/Region.py b/Region.py
index 799b76928..17efafc8c 100644
--- a/Region.py
+++ b/Region.py
@@ -83,6 +83,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]:
@@ -90,10 +91,13 @@ 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
+ assert item.world is not None
+
if not manual and self.world.settings.empty_dungeons_mode != 'none' and item.dungeonitem:
# An empty dungeon can only store its own dungeon items
if self.dungeon and self.dungeon.world.empty_dungeons[self.dungeon.name].empty:
@@ -127,7 +131,9 @@ def can_fill(self, item: Item, manual: bool = False) -> bool:
if item.name in REWARD_COLORS:
is_hint_color_restricted = [REWARD_COLORS[item.name]] if shuffle_setting == 'regional' else None
else:
- is_hint_color_restricted = [HintArea.for_dungeon(item.name).color] if shuffle_setting == 'regional' else None
+ hint_area = HintArea.for_dungeon(item.name)
+ assert hint_area is not None
+ is_hint_color_restricted = [hint_area.color] if shuffle_setting == 'regional' else None
is_dungeon_restricted = shuffle_setting == 'any_dungeon'
is_overworld_restricted = shuffle_setting == 'overworld'
diff --git a/Rom.py b/Rom.py
index 1699910cc..d8c6fe26a 100644
--- a/Rom.py
+++ b/Rom.py
@@ -28,7 +28,7 @@ def __init__(self, file: Optional[str] = None) -> None:
with open(data_path('generated/symbols.json'), 'r') as stream:
symbols = json.load(stream)
- self.symbols: dict[str, int] = {name: {'address': int(sym['address'], 16), 'length': sym['length']} for name, sym in symbols.items()}
+ self.symbols: dict[str, dict[str, int]] = {name: {'address': int(sym['address'], 16), 'length': sym['length']} for name, sym in symbols.items()}
if file is None:
return
@@ -129,7 +129,7 @@ def decompress_rom(self, input_file: str, output_file: str, verify_crc: bool = T
subprocess.check_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
@@ -145,7 +145,9 @@ def write_bytes_restrictive(self, start: int, size: int, values: Sequence[int])
if should_write:
self.write_byte(address, values[i])
- def write_bytes(self, address: int, values: Sequence[int]) -> None:
+ def write_bytes(self, address: Optional[int], values: Sequence[int]) -> None:
+ if address is None:
+ address = self.last_address
super().write_bytes(address, values)
self.changed_address.update(zip(range(address, address + len(values)), values))
diff --git a/RuleParser.py b/RuleParser.py
index 93f132219..811dfcad5 100644
--- a/RuleParser.py
+++ b/RuleParser.py
@@ -137,13 +137,13 @@ 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))
if isinstance(item, ast.Constant) and isinstance(item.value, str):
item = ast.Name(id=escape_name(item.value), ctx=ast.Load())
+ if not isinstance(item, ast.Name):
+ 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 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 f6a736bff..64744217f 100644
--- a/Rules.py
+++ b/Rules.py
@@ -25,13 +25,15 @@ 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
- add_item_rule(location, lambda location, item:
+ add_item_rule(location, lambda location, item: location.world is not None and (
((location.world.distribution.songs_as_items or any(name in song_list and record.count for name, record in world.settings.starting_items.items()))
and item.type != 'Song')
- or (item.type == 'Song' and item.world.id == location.world.id))
+ or (item.type == 'Song' and item.world is not None and item.world.id == location.world.id)
+ ))
else:
add_item_rule(location, lambda location, item: item.type != 'Song')
@@ -40,20 +42,20 @@ def set_rules(world: World) -> None:
add_item_rule(location, lambda location, item: item.type != 'Shop')
location.price = world.shop_prices[location.name]
# If price was specified in plando, use it here so access rule is set correctly.
- if location.name in world.distribution.locations and world.distribution.locations[location.name].price is not None:
- price = world.distribution.locations[location.name].price
- if price > 999: # Cap positive values above 999 so that they're not impossible.
- world.distribution.locations[location.name].price = 999
- price = 999
- elif price < -32768: # Prices below this will error on patching.
- world.distribution.locations[location.name].price = -32768
- price = -32768
- location.price = price
- world.shop_prices[location.name] = price
+ if world.distribution.locations is not None and location.name in world.distribution.locations:
+ dist_location = world.distribution.locations[location.name]
+ assert not isinstance(dist_location, list)
+ if dist_location.price is not None:
+ if dist_location.price > 999: # Cap positive values above 999 so that they're not impossible.
+ dist_location.price = 999
+ elif dist_location.price < -32768: # Prices below this will error on patching.
+ dist_location.price = -32768
+ location.price = dist_location.price
+ world.shop_prices[location.name] = dist_location.price
location.add_rule(create_shop_rule(location))
else:
- add_item_rule(location, lambda location, item: item.type == 'Shop' and item.world.id == location.world.id)
- elif location.type in ['Scrub', 'GrottoScrub']:
+ add_item_rule(location, lambda location, item: item.type == 'Shop' and item.world is not None and location.world is not None and item.world.id == location.world.id)
+ elif location.type in ('Scrub', 'GrottoScrub'):
location.add_rule(create_shop_rule(location))
else:
add_item_rule(location, lambda location, item: item.type != 'Shop')
@@ -97,6 +99,8 @@ def required_wallets(price: Optional[int]) -> int:
if price > 99:
return 1
return 0
+
+ assert location.world is not None
return location.world.parser.parse_rule('(Progressive_Wallet, %d)' % required_wallets(location.price))
@@ -138,6 +142,7 @@ def set_shop_rules(world: World):
wallet2 = world.parser.parse_rule('(Progressive_Wallet, 2)')
is_adult = world.parser.parse_rule('is_adult')
for location in world.get_filled_locations():
+ assert location.item is not None
if location.item.type == 'Shop':
# Add wallet requirements
if location.item.name in ['Buy Fish', 'Buy Goron Tunic', 'Buy Bombchu (20)', 'Buy Bombs (30)']:
@@ -160,7 +165,7 @@ def set_shop_rules(world: World):
'Buy Red Potion for 40 Rupees',
'Buy Red Potion for 50 Rupees',
"Buy Fairy's Spirit"]:
- location.add_rule(State.has_bottle)
+ location.add_rule(State.has_bottle) # type: ignore #TODO figure out why mypy doesn't accept this
if location.item.name in ['Buy Bombchu (10)', 'Buy Bombchu (20)', 'Buy Bombchu (5)']:
location.add_rule(found_bombchus)
@@ -179,6 +184,7 @@ def set_entrances_based_rules(worlds: Collection[World]) -> None:
if location.type == 'Shop':
# If All Locations Reachable is on, prevent shops only ever reachable as child from containing Buy Goron Tunic and Buy Zora Tunic items
if not world.check_beatable_only:
+ assert location.parent_region is not None
if not search.can_reach(location.parent_region, age='adult'):
forbid_item(location, 'Buy Goron Tunic')
forbid_item(location, 'Buy Zora Tunic')
diff --git a/SaveContext.py b/SaveContext.py
index 12b6a8aea..5af58d630 100644
--- a/SaveContext.py
+++ b/SaveContext.py
@@ -13,7 +13,7 @@
from Rom import Rom
from World import World
-AddressesDict: TypeAlias = "dict[str, Address | dict[str, Address | dict[str, Address]]]"
+AddressesDict: TypeAlias = "Any" #dict[str, Address | dict[str, Address | dict[str, Address]]]
class Scenes(IntEnum):
@@ -55,12 +55,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 | str] = None
self.size: int = size
self.choices: Optional[dict[str, int]] = choices
self.mask: int = mask
@@ -85,6 +85,7 @@ def get_value_raw(self) -> Optional[int]:
value = self.value
if self.choices is not None:
+ assert isinstance(value, str)
value = self.choices[value]
if not isinstance(value, int):
raise ValueError("Invalid value type '%s'" % str(value))
@@ -112,8 +113,8 @@ def set_value_raw(self, value: int) -> None:
if self.choices is not None:
for choice_name, choice_value in self.choices.items():
if choice_value == value:
- value = choice_name
- break
+ self.value = choice_name
+ return
self.value = value
@@ -138,7 +139,7 @@ def get_writes(self, save_context: SaveContext) -> None:
@staticmethod
def to_bytes(value: int, size: int) -> list[int]:
- ret = []
+ ret: list[int] = []
for _ in range(size):
ret.insert(0, value & 0xFF)
value = value >> 8
@@ -146,7 +147,7 @@ def to_bytes(value: int, size: int) -> list[int]:
class SaveContext:
- def __init__(self):
+ def __init__(self) -> None:
self.save_bits: dict[int, int] = {}
self.save_bytes: dict[int, int] = {}
self.addresses: AddressesDict = self.get_save_context_addresses()
@@ -238,8 +239,8 @@ def write_save_table(self, rom: Rom) -> None:
for name, address in self.addresses.items():
self.write_save_entry(address)
- save_table = []
- extended_table = []
+ save_table: list[int] = []
+ extended_table: list[int] = []
for address, value in self.save_bits.items():
table = save_table
if address >= Address.EXTENDED_CONTEXT_START:
@@ -409,7 +410,7 @@ def give_item(self, world: World, item: str, count: int = 1) -> None:
"Spirit Temple": 'dungeon_items.spirit.boss_key',
"Shadow Temple": 'dungeon_items.shadow.boss_key',
}
- save_writes[dungeon][bk_names[dungeon]] = True
+ save_writes[dungeon][bk_names[dungeon]] = True # type: ignore #TODO
else:
save_writes = SaveContext.save_writes_table[item]
for address, value in save_writes.items():
@@ -422,21 +423,24 @@ def give_item(self, world: World, item: str, count: int = 1) -> None:
address_value = self.addresses
prev_sub_address = 'Save Context'
- sub_address = None
+ sub_address: Any = None
for sub_address in address.split('.'):
if sub_address not in address_value:
raise ValueError('Unknown key %s in %s of SaveContext' % (sub_address, prev_sub_address))
if isinstance(address_value, list):
- sub_address = int(sub_address)
+ sub_address = int(sub_address)
address_value = address_value[sub_address]
prev_sub_address = sub_address
if not isinstance(address_value, Address):
raise ValueError('%s does not resolve to an Address in SaveContext' % sub_address)
- if isinstance(value, int) and value < address_value.get_value():
- continue
+ if isinstance(value, int):
+ found_value = address_value.get_value()
+ assert isinstance(found_value, int)
+ if value < found_value:
+ continue
address_value.value = value
else:
diff --git a/SceneFlags.py b/SceneFlags.py
index 5f57e6abd..0a4d29c8b 100644
--- a/SceneFlags.py
+++ b/SceneFlags.py
@@ -1,19 +1,27 @@
from __future__ import annotations
+import sys
from math import ceil
from typing import TYPE_CHECKING
+if sys.version_info >= (3, 10):
+ from typing import TypeAlias
+else:
+ TypeAlias = str
+
if TYPE_CHECKING:
from Location import Location
from World import World
+XFlags: TypeAlias = "dict[int, dict[tuple[int, int], list[tuple[int, int]]]]"
+
# Loop through all of the locations in the world. Extract ones that use our flag system to start building our xflag tables
-def build_xflags_from_world(world: World) -> tuple[dict[int, dict[tuple[int, int], list[tuple[int, int]]]], list[tuple[Location, tuple[int, int, int, int], tuple[int, int, int, int]]]]:
- scene_flags = {}
+def build_xflags_from_world(world: World) -> tuple[XFlags, list[tuple[Location, tuple[int, int, int, int], tuple[int, int, int, int]]]]:
+ scene_flags: XFlags = {}
alt_list = []
for i in range(0, 101):
scene_flags[i] = {}
for location in world.get_locations():
- if location.scene == i and location.type in ["Freestanding", "Pot", "FlyingPot", "Crate", "SmallCrate", "Beehive", "RupeeTower", "SilverRupee", "Wonderitem"]:
+ if location.scene == i and location.type in ("Freestanding", "Pot", "FlyingPot", "Crate", "SmallCrate", "Beehive", "RupeeTower", "SilverRupee", "Wonderitem"):
default = location.default
if isinstance(default, list): # List of alternative room/setup/flag to use
primary_tuple = default[0]
@@ -21,12 +29,14 @@ def build_xflags_from_world(world: World) -> tuple[dict[int, dict[tuple[int, in
room, setup, flag = primary_tuple
subflag = 0
primary_tuple = (room, setup, flag, subflag)
+ assert len(primary_tuple) == 4
for c in range(1, len(default)):
alt = default[c]
if len(alt) == 3:
room, setup, flag = alt
subflag = 0
alt = (room, setup, flag, subflag)
+ assert len(alt) == 4
alt_list.append((location, alt, primary_tuple))
default = primary_tuple # Use the first tuple as the primary tuple
if isinstance(default, tuple):
@@ -36,7 +46,7 @@ def build_xflags_from_world(world: World) -> tuple[dict[int, dict[tuple[int, in
elif len(default) == 4:
room, setup, flag, subflag = default
room_setup = (setup, room)
- if not room_setup in scene_flags[i].keys():
+ if room_setup not in scene_flags[i].keys():
scene_flags[i][room_setup] = []
scene_flags[i][room_setup].append((flag, subflag))
@@ -45,7 +55,7 @@ def build_xflags_from_world(world: World) -> tuple[dict[int, dict[tuple[int, in
return scene_flags, alt_list
# Take the data from build_xflags_from_world and create the actual tables that will be stored in the ROM
-def build_xflag_tables(xflags: dict[int, dict[tuple[int,int], list[tuple[int,int]]]]) -> tuple[bytearray, bytearray, bytearray, int]:
+def build_xflag_tables(xflags: XFlags) -> tuple[bytearray, bytearray, bytearray, int]:
scene_table = bytearray([0xFF] * 202)
room_table = bytearray(0)
room_blob = bytearray(0)
@@ -129,7 +139,7 @@ def get_collectible_flag_table_bytes(scene_flag_table: dict[int, dict[int, int]]
return bytes, num_flag_bytes
# Build a list of alternative overrides for alternate scene setups
-def get_alt_list_bytes(alt_list: list[tuple[Location, tuple[int, int, int], tuple[int, int, int]]]) -> bytearray:
+def get_alt_list_bytes(alt_list: list[tuple[Location, tuple[int, int, int, int], tuple[int, int, int, int]]]) -> bytearray:
bytes = bytearray()
for entry in alt_list:
location, alt, primary = entry
diff --git a/Search.py b/Search.py
index 88519d041..aa9edc7c5 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
@@ -153,6 +153,7 @@ def _expand_tod_regions(self, regions: dict[Region, int], goal_region: Region, a
for exit in exit_queue:
# We don't look for new regions, just spreading the tod to our existing regions
if exit.connected_region in regions and tod & ~regions[exit.connected_region]:
+ assert exit.world is not None
# Evaluate the access rule directly
if exit.access_rule(self.state_list[exit.world.id], spot=exit, age=age, tod=tod):
regions[exit.connected_region] |= tod
@@ -197,6 +198,7 @@ def iter_reachable_locations(self, item_locations: Iterable[Location]) -> Iterab
for loc in item_locations:
if loc in visited_locations:
continue
+ assert loc.world is not None
# Check adult first; it's the most likely.
if (loc.parent_region in adult_regions
and loc.access_rule(self.state_list[loc.world.id], spot=loc, age='adult')):
@@ -218,6 +220,7 @@ def iter_reachable_locations(self, item_locations: Iterable[Location]) -> Iterab
def collect_locations(self, item_locations: Optional[Iterable[Location]] = None) -> None:
item_locations = item_locations or self.progression_locations()
for location in self.iter_reachable_locations(item_locations):
+ assert location.item is not None
# Collect the item for the state world it is for
self.collect(location.item)
@@ -280,9 +283,8 @@ def beatable_goals(self, goal_categories: dict[str, GoalCategory]) -> ValidGoals
def test_category_goals(self, goal_categories: dict[str, GoalCategory], world_filter: Optional[int] = None) -> ValidGoals:
valid_goals: ValidGoals = {}
- for category_name, category in goal_categories.items():
- valid_goals[category_name] = {}
- valid_goals[category_name]['stateReverse'] = {}
+ for category_name in goal_categories:
+ valid_goals[category_name] = {'stateReverse': {}}
for state in self.state_list:
# Must explicitly test for None as the world filter can be 0
# for the first world ID
@@ -291,19 +293,19 @@ def test_category_goals(self, goal_categories: dict[str, GoalCategory], world_fi
# multiworld
if world_filter is not None and state.world.id != world_filter:
continue
- valid_goals[category_name]['stateReverse'][state.world.id] = []
+ valid_goals[category_name]['stateReverse'][state.world.id] = [] # type: ignore
world_category = state.world.goal_categories.get(category_name, None)
if world_category is None:
continue
for goal in world_category.goals:
- if goal.name not in valid_goals[category_name]:
- valid_goals[category_name][goal.name] = []
+ if goal.name not in valid_goals[category_name]: # type: ignore
+ valid_goals[category_name][goal.name] = [] # type: ignore
# Check if already beaten
if all(map(lambda i: state.has_full_item_goal(world_category, goal, i), goal.items)):
- valid_goals[category_name][goal.name].append(state.world.id)
+ valid_goals[category_name][goal.name].append(state.world.id) # type: ignore
# Reverse lookup for checking if the category is already beaten.
# Only used to check if starting items satisfy the category.
- valid_goals[category_name]['stateReverse'][state.world.id].append(goal.name)
+ valid_goals[category_name]['stateReverse'][state.world.id].append(goal.name) # type: ignore
return valid_goals
def iter_pseudo_starting_locations(self) -> Iterable[Location]:
@@ -324,12 +326,12 @@ def collect_pseudo_starting_items(self) -> None:
def can_reach(self, region: Region, age: Optional[str] = None, tod: int = TimeOfDay.NONE) -> bool:
if age == 'adult':
if tod:
- return region in self._cache.adult_regions and (self._cache.adult_regions[region] & tod or self._expand_tod_regions(self._cache.adult_regions, region, age, tod))
+ return region in self._cache.adult_regions and (self._cache.adult_regions[region] & tod or self._expand_tod_regions(self._cache.adult_regions, region, age, tod)) != 0
else:
return region in self._cache.adult_regions
elif age == 'child':
if tod:
- return region in self._cache.child_regions and (self._cache.child_regions[region] & tod or self._expand_tod_regions(self._cache.child_regions, region, age, tod))
+ return region in self._cache.child_regions and (self._cache.child_regions[region] & tod or self._expand_tod_regions(self._cache.child_regions, region, age, tod)) != 0
else:
return region in self._cache.child_regions
elif age == 'both':
@@ -359,6 +361,8 @@ def reachable_regions(self, age: Optional[str] = None) -> set[Region]:
# Returns whether the given age can access the spot at this age and tod,
# by checking whether the search has reached the containing region, and evaluating the spot's access rule.
def spot_access(self, spot: Location | Entrance, age: Optional[str] = None, tod: int = TimeOfDay.NONE) -> bool:
+ assert spot.parent_region is not None
+ assert spot.world is not None
if age == 'adult' or age == 'child':
return (self.can_reach(spot.parent_region, age=age, tod=tod)
and spot.access_rule(self.state_list[spot.world.id], spot=spot, age=age, tod=tod))
diff --git a/SettingTypes.py b/SettingTypes.py
index 9a6ac734b..9fbed50ea 100644
--- a/SettingTypes.py
+++ b/SettingTypes.py
@@ -1,16 +1,20 @@
from __future__ import annotations
+from collections.abc import Callable
import math
import operator
-from typing import Optional, Any
+from typing import Optional, Any, TYPE_CHECKING
from Utils import powerset
+if TYPE_CHECKING:
+ from Settings import Settings
+
# holds the info for a single setting
class SettingInfo:
def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Optional[str], shared: bool,
choices: Optional[dict | list] = None, default: Any = None, disabled_default: Any = None,
- disable: Optional[dict] = None, gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None,
+ disable: Optional[dict] = None, gui_tooltip: Optional[str] = None, gui_params: Optional[dict[str, Any]] = None,
cosmetic: bool = False) -> None:
self.type: type = setting_type # type of the setting's value, used to properly convert types to setting strings
self.shared: bool = shared # whether the setting is one that should be shared, used in converting settings to a string
@@ -20,22 +24,25 @@ def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Option
self.gui_tooltip: Optional[str] = "" if gui_tooltip is None else gui_tooltip
self.gui_params: dict[str, Any] = {} if gui_params is None else gui_params # additional parameters that the randomizer uses for the gui
self.disable: Optional[dict] = disable # dictionary of settings this setting disabled
- self.dependency = None # lambda that determines if this is disabled. Generated later
+ self.dependency: Optional[Callable[[Settings], bool]] = None # lambda that determines if this is disabled. Generated later
# 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
+ self.bitwidth: int
if shared:
- if self.gui_params.get('min') and self.gui_params.get('max') and not choices:
- self.bitwidth = math.ceil(math.log(self.gui_params.get('max') - self.gui_params.get('min') + 1, 2))
+ if 'min' in self.gui_params and 'max' in self.gui_params and not choices:
+ self.bitwidth = math.ceil(math.log(self.gui_params['max'] - self.gui_params['min'] + 1, 2))
else:
self.bitwidth = self.calc_bitwidth(choices)
else:
@@ -100,7 +107,7 @@ def create_dependency(self, disabling_setting: 'SettingInfo', option, negative:
class SettingInfoNone(SettingInfo):
def __init__(self, gui_text: Optional[str], gui_type: Optional[str], gui_tooltip: Optional[str] = None,
- gui_params: Optional[dict] = None) -> None:
+ gui_params: Optional[dict[str, Any]] = None) -> None:
super().__init__(setting_type=type(None), gui_text=gui_text, gui_type=gui_type, shared=False, choices=None,
default=None, disabled_default=None, disable=None, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=False)
@@ -115,7 +122,7 @@ def __set__(self, obj, value: str) -> None:
class SettingInfoBool(SettingInfo):
def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, default: Optional[bool] = None,
disabled_default: Optional[bool] = None, disable: Optional[dict] = None, gui_tooltip: Optional[str] = None,
- gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
choices = {
True: 'checked',
False: 'unchecked',
@@ -141,7 +148,7 @@ class SettingInfoStr(SettingInfo):
def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool = False,
choices: Optional[dict | list] = None, default: Optional[str] = None,
disabled_default: Optional[str] = None, disable: Optional[dict] = None,
- gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ gui_tooltip: Optional[str] = None, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(setting_type=str, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices,
default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -162,7 +169,7 @@ class SettingInfoInt(SettingInfo):
def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool,
choices: Optional[dict | list] = None, default: Optional[int] = None,
disabled_default: Optional[int] = None, disable: Optional[dict] = None,
- gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ gui_tooltip: Optional[str] = None, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(setting_type=int, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices,
default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -183,7 +190,7 @@ class SettingInfoList(SettingInfo):
def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool,
choices: Optional[dict | list] = None, default: Optional[list] = None,
disabled_default: Optional[list] = None, disable: Optional[dict] = None,
- gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ gui_tooltip: Optional[str] = None, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(setting_type=list, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices,
default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -204,7 +211,7 @@ class SettingInfoDict(SettingInfo):
def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool,
choices: Optional[dict | list] = None, default: Optional[dict] = None,
disabled_default: Optional[dict] = None, disable: Optional[dict] = None,
- gui_tooltip: Optional[str] = None, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ gui_tooltip: Optional[str] = None, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(setting_type=dict, gui_text=gui_text, gui_type=gui_type, shared=shared, choices=choices,
default=default, disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -223,20 +230,20 @@ def __set__(self, obj, value: dict) -> None:
class Button(SettingInfoNone):
def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None,
- gui_params: Optional[dict] = None) -> None:
+ gui_params: Optional[dict[str, Any]] = None) -> None:
super().__init__(gui_text=gui_text, gui_type="Button", gui_tooltip=gui_tooltip, gui_params=gui_params)
class Textbox(SettingInfoNone):
def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None,
- gui_params: Optional[dict] = None) -> None:
+ gui_params: Optional[dict[str, Any]] = None) -> None:
super().__init__(gui_text=gui_text, gui_type="Textbox", gui_tooltip=gui_tooltip, gui_params=gui_params)
class Checkbutton(SettingInfoBool):
def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, disable: Optional[dict] = None,
disabled_default: Optional[bool] = None, default: bool = False, shared: bool = False,
- gui_params: Optional[dict] = None, cosmetic: bool = False):
+ gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False):
super().__init__(gui_text=gui_text, gui_type='Checkbutton', shared=shared, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -245,7 +252,7 @@ def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, d
class Combobox(SettingInfoStr):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], default: Optional[str],
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='Combobox', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -254,7 +261,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], defa
class Radiobutton(SettingInfoStr):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], default: Optional[str],
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='Radiobutton', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -263,7 +270,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], defa
class Fileinput(SettingInfoStr):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list] = None, default: Optional[str] = None,
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='Fileinput', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -272,7 +279,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list] = Non
class Directoryinput(SettingInfoStr):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list] = None, default: Optional[str] = None,
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='Directoryinput', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -281,7 +288,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list] = Non
class Textinput(SettingInfoStr):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list] = None, default: Optional[str] = None,
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[str] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='Textinput', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -290,7 +297,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list] = Non
class ComboboxInt(SettingInfoInt):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], default: Optional[int],
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[int] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='Combobox', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -299,7 +306,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], defa
class Scale(SettingInfoInt):
def __init__(self, gui_text: Optional[str], default: Optional[int], minimum: int, maximum: int, step: int = 1,
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[int] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
choices = {
i: str(i) for i in range(minimum, maximum+1, step)
}
@@ -318,7 +325,7 @@ def __init__(self, gui_text: Optional[str], default: Optional[int], minimum: int
class Numberinput(SettingInfoInt):
def __init__(self, gui_text: Optional[str], default: Optional[int], minimum: Optional[int] = None,
maximum: Optional[int] = None, gui_tooltip: Optional[str] = None, disable: Optional[dict] = None,
- disabled_default: Optional[int] = None, shared: bool = False, gui_params: Optional[dict] = None,
+ disabled_default: Optional[int] = None, shared: bool = False, gui_params: Optional[dict[str, Any]] = None,
cosmetic: bool = False) -> None:
if gui_params is None:
gui_params = {}
@@ -335,7 +342,7 @@ def __init__(self, gui_text: Optional[str], default: Optional[int], minimum: Opt
class MultipleSelect(SettingInfoList):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], default: Optional[list],
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[list] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='MultipleSelect', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
@@ -344,7 +351,7 @@ def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], defa
class SearchBox(SettingInfoList):
def __init__(self, gui_text: Optional[str], choices: Optional[dict | list], default: Optional[list],
gui_tooltip: Optional[str] = None, disable: Optional[dict] = None, disabled_default: Optional[list] = None,
- shared: bool = False, gui_params: Optional[dict] = None, cosmetic: bool = False) -> None:
+ shared: bool = False, gui_params: Optional[dict[str, Any]] = None, cosmetic: bool = False) -> None:
super().__init__(gui_text=gui_text, gui_type='SearchBox', shared=shared, choices=choices, default=default,
disabled_default=disabled_default, disable=disable, gui_tooltip=gui_tooltip,
gui_params=gui_params, cosmetic=cosmetic)
diff --git a/Settings.py b/Settings.py
index 1753d9c91..5ba72396a 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
@@ -198,7 +199,7 @@ def update_with_settings_string(self, text: str) -> None:
for setting in filter(lambda s: s.shared and s.bitwidth > 0, self.setting_infos.values()):
cur_bits = bits[:setting.bitwidth]
bits = bits[setting.bitwidth:]
- value = None
+ value: Any = None
if setting.type == bool:
value = True if cur_bits[0] == 1 else False
elif setting.type == str:
@@ -358,7 +359,7 @@ def to_json(self, *, legacy_starting_items: bool = False) -> dict[str, Any]:
if legacy_starting_items:
settings = self.copy()
for setting_name, items in LEGACY_STARTING_ITEM_SETTINGS.items():
- starting_items = []
+ starting_items: list[str] = []
setattr(settings, setting_name, starting_items)
for entry in items.values():
if entry.item_name in self.starting_items:
diff --git a/SettingsList.py b/SettingsList.py
index f69a270ac..a9484699f 100644
--- a/SettingsList.py
+++ b/SettingsList.py
@@ -23,7 +23,7 @@
# Old/New name of a setting
class Setting_Info_Versioning:
- def __init__(self, old_name, new_name):
+ def __init__(self, old_name, new_name) -> None:
self.old_name = old_name # old name of the setting
self.new_name = new_name # new name of the setting
@@ -5491,12 +5491,14 @@ 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':
source = location_table.keys()
elif value_type == 'entrance':
+ assert isinstance(source_list, dict)
+ assert isinstance(source, list)
for pool in source_list.values():
for entrance in pool:
source.append(entrance.name)
@@ -5505,6 +5507,7 @@ def build_close_match(name: str, value_type: str, source_list: Optional[list[str
elif value_type == 'setting':
source = SettingInfos.setting_infos.keys()
elif value_type == 'choice':
+ assert source_list is not None
source = source_list
# Ensure name and source are type string to prevent errors
close_match = difflib.get_close_matches(str(name), map(str, source), 1)
@@ -5534,6 +5537,7 @@ def validate_settings(settings_dict: dict[str, Any], *, check_conflicts: bool =
continue
# Ensure that the given choice is a valid choice for the setting
elif info.choice_list and choice not in info.choice_list:
+ assert isinstance(choice, str)
raise ValueError('%r is not a valid choice for setting %r. %s' % (choice, setting, build_close_match(choice, 'choice', info.choice_list)))
# Ensure no conflicting settings are specified
if check_conflicts and info.disable is not None:
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..d830bfd0d 100755
--- a/SettingsToJson.py
+++ b/SettingsToJson.py
@@ -3,12 +3,22 @@
import copy
import json
import sys
-from typing import Any, Optional
+from typing import TYPE_CHECKING, Any, Optional
from Hints import hint_dist_files
from SettingsList import SettingInfos, get_settings_from_section, get_settings_from_tab
from Utils import data_path
+if TYPE_CHECKING:
+ from typing import TypedDict
+
+ class SettingsListJson(TypedDict):
+ settingsObj: dict[str, Any]
+ settingsArray: list[Any]
+ cosmeticsObj: dict[str, Any]
+ cosmeticsArray: list[Any]
+ distroArray: list[Any]
+
tab_keys: list[str] = ['text', 'app_type', 'footer']
section_keys: list[str] = ['text', 'app_type', 'is_colors', 'is_sfx', 'col_span', 'row_span', 'subheader']
@@ -69,7 +79,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,
}
@@ -105,6 +115,7 @@ def get_setting_json(setting: str, web_version: bool, as_array: bool = False) ->
if key in setting_keys and (key not in version_specific_keys or version_specific):
setting_json[key] = value
if key == 'disable':
+ assert isinstance(value, dict)
for option, types in value.items():
for s in types.get('settings', []):
if SettingInfos.setting_infos[s].shared:
@@ -144,12 +155,12 @@ def get_setting_json(setting: str, web_version: bool, as_array: bool = False) ->
if option_name in setting_disable:
add_disable_option_to_json(setting_disable[option_name], option_json)
- option_tooltip = setting_info.gui_params.get('choice_tooltip', {}).get(option_name, None)
+ option_tooltip = setting_info.gui_params.get('choice_tooltip', {}).get(option_name, None) # type: ignore # mypy doesn't know about dict.get???
if option_tooltip is not None:
option_json['tooltip'] = remove_trailing_lines(
'
'.join(line.strip() for line in option_tooltip.split('\n')))
- option_filter = setting_info.gui_params.get('filterdata', {}).get(option_name, None)
+ option_filter = setting_info.gui_params.get('filterdata', {}).get(option_name, None) # type: ignore # mypy doesn't know about dict.get???
if option_filter is not None:
option_json['tags'] = option_filter
for tag in option_filter:
@@ -237,7 +248,7 @@ def get_tab_json(tab: dict[str, Any], web_version: bool, as_array: bool = False)
def create_settings_list_json(path: str, web_version: bool = False) -> None:
- output_json = {
+ output_json: SettingsListJson = {
'settingsObj' : {},
'settingsArray' : [],
'cosmeticsObj' : {},
diff --git a/Spoiler.py b/Spoiler.py
index 09f381eec..17f0516ce 100644
--- a/Spoiler.py
+++ b/Spoiler.py
@@ -79,8 +79,11 @@ def __init__(self, worlds: list[World]) -> None:
self.file_hash: list[int] = []
self.password: list[int] = []
+ self._cached_named_item_locations: Optional[list[Location]] = None
+ self._cached_always_locations: Optional[list[tuple[str, int]]] = None
+
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 52b17328b..13c2b25df 100644
--- a/State.py
+++ b/State.py
@@ -6,7 +6,7 @@
from RulesCommon import escape_name
if TYPE_CHECKING:
- from Goals import GoalCategory, Goal
+ from Goals import Goal, GoalCategory, GoalItem
from Location import Location
from Search import Search
from World import World
@@ -103,10 +103,10 @@ def has_ocarina_buttons(self, count: int) -> bool:
return self.count_distinct(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 d23d80c46..3f79a8a16 100644
--- a/Unittest.py
+++ b/Unittest.py
@@ -135,7 +135,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 148cdf982..b43385270 100644
--- a/Utils.py
+++ b/Utils.py
@@ -19,36 +19,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:
@@ -123,22 +123,21 @@ class VersionError(Exception):
pass
-def check_version(checked_version: str) -> None:
- if not hasattr(check_version, "base_regex"):
- check_version.base_regex = re.compile("""^[ \t]*__version__ = ['"](.+)['"]""", flags=re.MULTILINE)
- check_version.supplementary_regex = re.compile(r"^[ \t]*supplementary_version = (\d+)$", flags=re.MULTILINE)
- check_version.full_regex = re.compile("""^[ \t]*__version__ = f['"]*(.+)['"]""", flags=re.MULTILINE)
- check_version.url_regex = re.compile("""^[ \t]*branch_url = ['"](.+)['"]""", flags=re.MULTILINE)
+BASE_REGEX = re.compile("""^[ \t]*__version__ = ['"](.+)['"]""", flags=re.MULTILINE)
+SUPPLEMENTARY_REGEX = re.compile(r"^[ \t]*supplementary_version = (\d+)$", flags=re.MULTILINE)
+FULL_REGEX = re.compile("""^[ \t]*__version__ = f['"]*(.+)['"]""", flags=re.MULTILINE)
+URL_REGEX = re.compile("""^[ \t]*branch_url = ['"](.+)['"]""", flags=re.MULTILINE)
+def check_version(checked_version: str) -> None:
if compare_version(checked_version, __version__) < 0:
try:
with urllib.request.urlopen(f'{branch_url.replace("https://github.com", "https://raw.githubusercontent.com").replace("tree/", "")}/version.py') as versionurl:
version_file = versionurl.read().decode("utf-8")
- base_match = check_version.base_regex.search(version_file, re.MULTILINE)
- supplementary_match = check_version.supplementary_regex.search(version_file, re.MULTILINE)
- full_match = check_version.full_regex.search(version_file, re.MULTILINE)
- url_match = check_version.url_regex.search(version_file, re.MULTILINE)
+ base_match = BASE_REGEX.search(version_file, re.MULTILINE)
+ supplementary_match = SUPPLEMENTARY_REGEX.search(version_file, re.MULTILINE)
+ full_match = FULL_REGEX.search(version_file, re.MULTILINE)
+ url_match = URL_REGEX.search(version_file, re.MULTILINE)
remote_base_version = base_match.group(1) if base_match else ""
remote_supplementary_version = int(supplementary_match.group(1)) if supplementary_match else 0
@@ -167,6 +166,8 @@ def check_version(checked_version: str) -> None:
# not, on Windows and Linux. Typical use::
# subprocess.call(['program_to_run', 'arg_1'], **subprocess_args())
def subprocess_args(include_stdout: bool = True) -> dict[str, Any]:
+ ret: dict[str, Any]
+
# The following is true only on Windows.
if hasattr(subprocess, 'STARTUPINFO'):
# On Windows, subprocess calls will pop up a command window by default
@@ -199,11 +200,12 @@ def subprocess_args(include_stdout: bool = True) -> dict[str, Any]:
return ret
-def run_process(logger: logging.Logger, args: Sequence[str], stdin: Optional[AnyStr] = None, *, check: bool = False) -> None:
+def run_process(logger: logging.Logger, args: Sequence[str], stdin: Optional[bytes] = None, *, check: bool = False) -> None:
process = subprocess.Popen(args, bufsize=1, stdout=subprocess.PIPE, stdin=subprocess.PIPE)
if stdin is not None:
process.communicate(input=stdin)
else:
+ assert process.stdout is not None
while True:
line = process.stdout.readline()
if line != b'':
diff --git a/World.py b/World.py
index 759c30de4..d792843f0 100644
--- a/World.py
+++ b/World.py
@@ -5,21 +5,21 @@
import os
import random
from collections import OrderedDict, defaultdict
-from collections.abc import Iterable, Iterator
+from collections.abc import Callable, Iterable, Iterator
from typing import Any, Optional
from Dungeon import Dungeon
from Entrance import Entrance
-from Goals import Goal, GoalCategory
+from Goals import Goal, GoalCategory, GoalItem
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 ItemList import REWARD_COLORS
from ItemPool import reward_list
from Location import Location, LocationFactory
-from LocationList import business_scrubs, location_groups, location_table
-from OcarinaSongs import generate_song_list, Song
-from Plandomizer import WorldDistribution, InvalidFileException
+from LocationList import LocationDefault, business_scrubs, location_groups, location_table
+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
@@ -55,6 +55,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.cached_bigocto_location: Optional[Location] = None
@@ -88,13 +89,14 @@ def __init__(self, world_id: int, settings: Settings, resolve_randomized_setting
self.disable_trade_revert: bool = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.adult_trade_shuffle
self.skip_child_zelda: bool = 'Zeldas Letter' not in settings.shuffle_child_trade and \
'Zeldas Letter' in self.distribution.starting_items
- self.selected_adult_trade_item: str = None
+ self.selected_adult_trade_item: Optional[str] = None
if not settings.adult_trade_shuffle and settings.adult_trade_start:
+ assert self.distribution.locations is not None
self.selected_adult_trade_item = random.choice(settings.adult_trade_start)
# Override the adult trade item used to control trade quest flags during patching if any are placed in plando.
# This has to run here because the rule parser caches world attributes and this attribute impacts logic for buying a blue potion from Granny's Potion shop.
adult_trade_matcher = self.distribution.pattern_matcher("#AdultTrade")
- plando_adult_trade = list(filter(lambda location_record_pair: adult_trade_matcher(location_record_pair[1].item), self.distribution.pattern_dict_items(self.distribution.locations)))
+ plando_adult_trade: list[tuple[str, Any]] = list(filter(lambda location_record_pair: adult_trade_matcher(location_record_pair[1].item), self.distribution.pattern_dict_items(self.distribution.locations)))
if plando_adult_trade:
self.selected_adult_trade_item = plando_adult_trade[0][1].item # ugly but functional, see the loop in Plandomizer.WorldDistribution.fill for how this is indexed
self.adult_trade_starting_inventory: str = ''
@@ -131,7 +133,7 @@ def __init__(self, boss_name: Optional[str]) -> None:
self.boss_name: Optional[str] = boss_name
self.hint_name: Optional[HintArea] = None
- def __init__(self):
+ def __init__(self) -> None:
super().__init__()
self['Deku Tree'] = self.EmptyDungeonInfo('Queen Gohma')
self['Dodongos Cavern'] = self.EmptyDungeonInfo('King Dodongo')
@@ -143,7 +145,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:
@@ -324,7 +326,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:
@@ -355,7 +357,7 @@ def __missing__(self, dungeon_name: str) -> EmptyDungeonInfo:
self.one_hint_per_goal = self.hint_dist_user['one_hint_per_goal']
# initialize category check for first rounds of goal hints
- self.hinted_categories = []
+ self.hinted_categories: list[str] = []
# Quick item lookup for All Goals Reachable setting
self.goal_items = []
@@ -421,6 +423,7 @@ def resolve_random_settings(self) -> None:
dist_keys = self.distribution.distribution.src_dict['_settings'].keys()
if self.settings.randomize_settings:
setting_info = SettingInfos.setting_infos['randomize_settings']
+ assert setting_info.disable is not None
self.randomized_list.extend(setting_info.disable[True]['settings'])
for section in setting_info.disable[True]['sections']:
self.randomized_list.extend(get_settings_from_section(section))
@@ -526,11 +529,12 @@ def resolve_random_settings(self) -> None:
if nb_to_pick < 0:
raise RuntimeError(f"{dist_num_empty} dungeons are set to empty on world {self.id+1}, but only {self.settings.empty_dungeons_count} empty dungeons allowed")
if len(empty_dungeon_pool) < nb_to_pick:
- non_empty = 8 - dist_num_empty - len(empty_dungeon_pool)
- raise RuntimeError(f"On world {self.id+1}, {dist_num_empty} dungeons are set to empty and {non_empty} to non-empty. Can't reach {self.settings.empty_dungeons_count} empty dungeons.")
+ num_non_empty = 8 - dist_num_empty - len(empty_dungeon_pool)
+ raise RuntimeError(f"On world {self.id+1}, {dist_num_empty} dungeons are set to empty and {num_non_empty} to non-empty. Can't reach {self.settings.empty_dungeons_count} empty dungeons.")
# Prioritize non-MQ dungeons
- non_mq, mq = [], []
+ non_mq: list[str] = []
+ mq: list[str] = []
for dung in empty_dungeon_pool:
(mq if self.dungeon_mq[dung] else non_mq).append(dung)
for dung in random.sample(non_mq, min(nb_to_pick, len(non_mq))):
@@ -552,11 +556,12 @@ def resolve_random_settings(self) -> None:
if nb_to_pick < 0:
raise RuntimeError("%d dungeons are set to MQ on world %d, but only %d MQ dungeons allowed." % (dist_num_mq, self.id+1, self.settings.mq_dungeons_count))
if len(mq_dungeon_pool) < nb_to_pick:
- non_mq = 8 - dist_num_mq - len(mq_dungeon_pool)
- raise RuntimeError(f"On world {self.id+1}, {dist_num_mq} dungeons are set to MQ and {non_mq} to non-MQ. Can't reach {self.settings.mq_dungeons_count} MQ dungeons.")
+ num_non_mq = 8 - dist_num_mq - len(mq_dungeon_pool)
+ raise RuntimeError(f"On world {self.id+1}, {dist_num_mq} dungeons are set to MQ and {num_non_mq} to non-MQ. Can't reach {self.settings.mq_dungeons_count} MQ dungeons.")
# Prioritize non-empty dungeons
- non_empty, empty = [], []
+ non_empty: list[str] = []
+ empty: list[str] = []
for dung in mq_dungeon_pool:
(empty if self.empty_dungeons[dung].empty else non_empty).append(dung)
for dung in random.sample(non_empty, min(nb_to_pick, len(non_empty))):
@@ -732,7 +737,7 @@ def random_shop_prices(self) -> None:
def set_scrub_prices(self) -> None:
# Get Deku Scrub Locations
scrub_locations = [location for location in self.get_locations() if location.type in ('Scrub', 'GrottoScrub')]
- scrub_dictionary = {}
+ scrub_dictionary: dict[LocationDefault, list[Location]] = {}
for location in scrub_locations:
if location.default not in scrub_dictionary:
scrub_dictionary[location.default] = []
@@ -785,7 +790,7 @@ def set_empty_dungeon_rewards(self, empty_rewards: list[str] = []) -> None:
for dungeon_item in self.empty_dungeons.items():
if dungeon_item[1].boss_name == boss:
dungeon_item[1].empty = True
- self.hint_type_overrides['barren'].append(dungeon_item[1].hint_name)
+ self.hint_type_overrides['barren'].append(str(dungeon_item[1].hint_name))
def set_goals(self) -> None:
# Default goals are divided into 3 primary categories:
@@ -844,7 +849,7 @@ def set_goals(self) -> None:
# the hint type even though the hintable location set is identical to WOTH.
if not self.settings.triforce_hunt:
if self.settings.starting_age == 'child':
- dot_items = [{'name': 'Temple of Time Access', 'quantity': 1, 'minimum': 1, 'hintable': True}]
+ dot_items: list[GoalItem] = [{'name': 'Temple of Time Access', 'quantity': 1, 'minimum': 1, 'hintable': True}]
if not self.settings.open_door_of_time:
dot_items.append({'name': 'Song of Time', 'quantity': 2 if self.settings.shuffle_song_items == 'any' and self.settings.item_pool_value == 'plentiful' else 1, 'minimum': 1, 'hintable': True})
if self.settings.shuffle_ocarinas:
@@ -1162,7 +1167,7 @@ def bigocto_location(self) -> Optional[Location]:
if region is not None and region.locations is not None
for loc in region.locations
if not loc.locked
- and loc.has_item()
+ and loc.item is not None
and not loc.item.event
and (loc.type != "Shop" or loc.name in self.shop_prices) # ignore regular shop items (but keep special deals)
]
@@ -1172,7 +1177,6 @@ def bigocto_location(self) -> Optional[Location]:
priority_types = (
"Wonderitem",
"Freestanding",
- "ActorOverride",
"RupeeTower",
"Pot",
"Crate",
@@ -1219,7 +1223,7 @@ def push_item(self, location: str | Location, item: Item, manual: bool = False)
item.price = location.price if location.price is not None else item.price
location.price = item.price
- logging.getLogger('').debug('Placed %s [World %d] at %s [World %d]', item, item.world.id if hasattr(item, 'world') else -1, location, location.world.id if hasattr(location, 'world') else -1)
+ logging.getLogger('').debug(f'Placed {item!r} at {location!r}')
def get_locations(self) -> list[Location]:
if not self._cached_locations:
@@ -1227,13 +1231,13 @@ def get_locations(self) -> list[Location]:
self._cached_locations.extend(region.locations)
return self._cached_locations
- def get_unfilled_locations(self) -> Iterable[Location]:
+ def get_unfilled_locations(self) -> Iterator[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) -> Iterator[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]:
+ def get_progression_locations(self) -> Iterator[Location]:
return filter(Location.has_progression_item, self.get_locations())
def get_entrances(self) -> list[Entrance]:
@@ -1301,7 +1305,7 @@ def update_useless_areas(self, spoiler: Spoiler) -> None:
or location.name in self.hint_exclusions
or location.item is None
or location.item.type == 'Event'
- or (location.item.type == 'DungeonReward' and location.item.world.settings.shuffle_dungeon_rewards in ('vanilla', 'reward', 'dungeon'))
+ or (location.item.type == 'DungeonReward' and location.item.world is not None and location.item.world.settings.shuffle_dungeon_rewards in ('vanilla', 'reward', 'dungeon'))
):
continue
@@ -1322,6 +1326,7 @@ def update_useless_areas(self, spoiler: Spoiler) -> None:
# check if any location in the area has a dungeon.
area_info['dungeon'] = False
for location in area_info['locations']:
+ assert location.parent_region is not None
if location.parent_region.dungeon is not None:
area_info['dungeon'] = True
break
@@ -1405,13 +1410,15 @@ def update_useless_areas(self, spoiler: Spoiler) -> None:
# is a progressive item. Normally this applies to things like bows, bombs
# bombchus, bottles, slingshot, magic and ocarina. However if plentiful
# item pool is enabled this could be applied to any item.
- duplicate_item_woth = {}
+ duplicate_item_woth: dict[int, dict[str, list[Location]]] = {}
woth_loc = [location for world_woth in spoiler.required_locations.values() for location in world_woth]
for world in spoiler.worlds:
duplicate_item_woth[world.id] = {}
for location in woth_loc:
- world_id = location.item.world.id
item = location.item
+ assert item is not None
+ assert item.world is not None
+ world_id = item.world.id
if item.name == 'Rutos Letter' and item.name in duplicate_item_woth[world_id]:
# Only the first Letter counts as a letter, subsequent ones are Bottles.
@@ -1437,11 +1444,16 @@ def update_useless_areas(self, spoiler: Spoiler) -> None:
for area, area_info in areas.items():
useless_area = True
for location in area_info['locations']:
- world_id = location.item.world.id
+ assert location.world is not None
item = location.item
-
- if ((not location.item.majoritem) or (location.item.name in exclude_item_list)) and \
- (location.item.name not in self.item_hint_type_overrides['barren']):
+ assert item is not None
+ assert item.world is not None
+ world_id = item.world.id
+
+ if (
+ (not item.majoritem or item.name in exclude_item_list)
+ and item.name not in self.item_hint_type_overrides['barren']
+ ):
# Minor items are always useless in logic
continue
@@ -1450,6 +1462,7 @@ def update_useless_areas(self, spoiler: Spoiler) -> None:
# If this is the required Letter then it is not useless
dupe_locations = duplicate_item_woth[world_id][item.name]
for dupe_location in dupe_locations:
+ assert dupe_location.world is not None
if dupe_location.world.id == location.world.id and dupe_location.name == location.name:
useless_area = False
break
@@ -1476,6 +1489,7 @@ def update_useless_areas(self, spoiler: Spoiler) -> None:
# If this is a required item location, then it is not useless
for dupe_location in dupe_locations:
+ assert dupe_location.world is not None
if dupe_location.world.id == location.world.id and dupe_location.name == location.name:
useless_area = False
break
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 000000000..e40efa3c2
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,22 @@
+[mypy]
+files = OoTRandomizer.py
+
+# the types of settings cannot be determined in HintList, see https://mypy.readthedocs.io/en/stable/error_code_list.html#check-that-type-of-target-is-known-has-type
+[mypy-HintList.*]
+disable_error_code = has-type
+
+#TODO also type check these modules
+[mypy-Audiobank.*]
+ignore_errors = True
+[mypy-Goals.*]
+ignore_errors = True
+[mypy-Music.*]
+ignore_errors = True
+[mypy-MusicHelpers.*]
+ignore_errors = True
+[mypy-Plandomizer.*]
+ignore_errors = True
+[mypy-RuleParser.*]
+ignore_errors = True
+[mypy-Spoiler.*]
+ignore_errors = True
diff --git a/texture_util.py b/texture_util.py
index 9f7dcc2fb..143a137e6 100755
--- a/texture_util.py
+++ b/texture_util.py
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
from __future__ import annotations
+from typing import Optional
from Rom import Rom
@@ -58,9 +59,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:
@@ -139,7 +140,7 @@ def rgba16_from_file(rom: Rom, base_texture_address: int, base_palette_address:
# size - Size of the texture in PIXELS
# patchfile - file path of a rgba16 binary texture to patch
# returns - bytearray of the new texture
-def rgba16_patch(rom: Rom, base_texture_address: int, base_palette_address: int, size: int, patchfile: str) -> bytearray:
+def rgba16_patch(rom: Rom, base_texture_address: int, base_palette_address: Optional[int], size: int, patchfile: Optional[str]) -> bytearray:
base_texture_rgba16 = load_rgba16_texture_from_rom(rom, base_texture_address, size)
patch_rgba16 = None
if patchfile:
@@ -158,11 +159,12 @@ def rgba16_patch(rom: Rom, base_texture_address: int, base_palette_address: int,
# size - Size of the texture in PIXELS
# patchfile - file path of a rgba16 binary texture to patch
# returns - bytearray of the new texture
-def ci4_rgba16patch_to_ci8(rom: Rom, base_texture_address: int, base_palette_address: int, size: int, patchfile: str) -> bytearray:
+def ci4_rgba16patch_to_ci8(rom: Rom, base_texture_address: int, base_palette_address: Optional[int], size: int, patchfile: Optional[str]) -> bytearray:
+ assert base_palette_address is not None
palette = load_palette(rom, base_palette_address, 16) # load the original palette from rom
base_texture_rgba16 = ci4_to_rgba16(rom, base_texture_address, size, palette) # load the original texture from rom and convert to ci8
patch_rgba16 = None
- if patchfile:
+ if patchfile is not None:
patch_rgba16 = load_rgba16_texture(patchfile, size)
new_texture_rgba16 = apply_rgba16_patch(base_texture_rgba16, patch_rgba16)
ci8_texture, ci8_palette = rgba16_to_ci8(new_texture_rgba16)