diff --git a/BaseClasses.py b/BaseClasses.py index f41894535170..2be9a9820d07 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -717,14 +717,23 @@ def can_reach(self, assert isinstance(player, int), "can_reach: player is required if spot is str" # try to resolve a name if resolution_hint == 'Location': - spot = self.multiworld.get_location(spot, player) + return self.can_reach_location(spot, player) elif resolution_hint == 'Entrance': - spot = self.multiworld.get_entrance(spot, player) + return self.can_reach_entrance(spot, player) else: # default to Region - spot = self.multiworld.get_region(spot, player) + return self.can_reach_region(spot, player) return spot.can_reach(self) + def can_reach_location(self, spot: str, player: int) -> bool: + return self.multiworld.get_location(spot, player).can_reach(self) + + def can_reach_entrance(self, spot: str, player: int) -> bool: + return self.multiworld.get_entrance(spot, player).can_reach(self) + + def can_reach_region(self, spot: str, player: int) -> bool: + return self.multiworld.get_region(spot, player).can_reach(self) + def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None: if locations is None: locations = self.multiworld.get_filled_locations() diff --git a/Launcher.py b/Launcher.py index 9e184bf1088d..890957958391 100644 --- a/Launcher.py +++ b/Launcher.py @@ -161,7 +161,7 @@ def launch(exe, in_terminal=False): def run_gui(): - from kvui import App, ContainerLayout, GridLayout, Button, Label + from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget from kivy.uix.image import AsyncImage from kivy.uix.relativelayout import RelativeLayout @@ -185,11 +185,16 @@ def build(self): self.container = ContainerLayout() self.grid = GridLayout(cols=2) self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General")) - self.grid.add_widget(Label(text="Clients")) - button_layout = self.grid # make buttons fill the window - - def build_button(component: Component): + self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) + self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) + tool_layout = ScrollBox() + tool_layout.layout.orientation = "vertical" + self.grid.add_widget(tool_layout) + client_layout = ScrollBox() + client_layout.layout.orientation = "vertical" + self.grid.add_widget(client_layout) + + def build_button(component: Component) -> Widget: """ Builds a button widget for a given component. @@ -200,31 +205,26 @@ def build_button(component: Component): None. The button is added to the parent grid layout. """ - button = Button(text=component.display_name) + button = Button(text=component.display_name, size_hint_y=None, height=40) button.component = component button.bind(on_release=self.component_action) if component.icon != "icon": image = AsyncImage(source=icon_paths[component.icon], size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout() + box_layout = RelativeLayout(size_hint_y=None, height=40) box_layout.add_widget(button) box_layout.add_widget(image) - button_layout.add_widget(box_layout) - else: - button_layout.add_widget(button) + return box_layout + return button for (tool, client) in itertools.zip_longest(itertools.chain( self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): # column 1 if tool: - build_button(tool[1]) - else: - button_layout.add_widget(Label()) + tool_layout.layout.add_widget(build_button(tool[1])) # column 2 if client: - build_button(client[1]) - else: - button_layout.add_widget(Label()) + client_layout.layout.add_widget(build_button(client[1])) return self.container diff --git a/MultiServer.py b/MultiServer.py index 15ed22d715e8..62dab3298e6b 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -656,7 +656,8 @@ def get_aliased_name(self, team: int, slot: int): else: return self.player_names[team, slot] - def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): + def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False, + recipients: typing.Sequence[int] = None): """Send and remember hints.""" if only_new: hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] @@ -685,12 +686,13 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b for slot in new_hint_events: self.on_new_hint(team, slot) for slot, hint_data in concerns.items(): - clients = self.clients[team].get(slot) - if not clients: - continue - client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] - for client in clients: - async_start(self.send_msgs(client, client_hints)) + if recipients is None or slot in recipients: + clients = self.clients[team].get(slot) + if not clients: + continue + client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] + for client in clients: + async_start(self.send_msgs(client, client_hints)) # "events" @@ -1429,9 +1431,13 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot] = hints - self.ctx.notify_hints(self.client.team, list(hints)) + self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,)) self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. " f"You have {points_available} points.") + if hints and Utils.version_tuple < (0, 5, 0): + self.output("It was recently changed, so that the above hints are only shown to you. " + "If you meant to alert another player of an above hint, " + "please let them know of the content or to run !hint themselves.") return True elif input_text.isnumeric(): diff --git a/Options.py b/Options.py index 2e3927aae3f3..139dc0a0bbe6 100644 --- a/Options.py +++ b/Options.py @@ -1,19 +1,18 @@ from __future__ import annotations import abc -import logging -from copy import deepcopy -from dataclasses import dataclass import functools +import logging import math import numbers import random import typing from copy import deepcopy +from dataclasses import dataclass from schema import And, Optional, Or, Schema -from Utils import get_fuzzy_results +from Utils import get_fuzzy_results, is_iterable_of_str if typing.TYPE_CHECKING: from BaseClasses import PlandoOptions @@ -59,6 +58,7 @@ def __new__(mcs, name, bases, attrs): def verify(self, *args, **kwargs) -> None: for f in verifiers: f(self, *args, **kwargs) + attrs["verify"] = verify else: assert verifiers, "class Option is supposed to implement def verify" @@ -183,6 +183,7 @@ def get_option_name(cls, value: str) -> str: class NumericOption(Option[int], numbers.Integral, abc.ABC): default = 0 + # note: some of the `typing.Any`` here is a result of unresolved issue in python standards # `int` is not a `numbers.Integral` according to the official typestubs # (even though isinstance(5, numbers.Integral) == True) @@ -598,7 +599,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P if isinstance(self.value, int): return from BaseClasses import PlandoOptions - if not(PlandoOptions.bosses & plando_options): + if not (PlandoOptions.bosses & plando_options): # plando is disabled but plando options were given so pull the option and change it to an int option = self.value.split(";")[-1] self.value = self.options[option] @@ -765,7 +766,7 @@ class VerifyKeys(metaclass=FreezeValidKeys): value: typing.Any @classmethod - def verify_keys(cls, data: typing.List[str]): + def verify_keys(cls, data: typing.Iterable[str]) -> None: if cls.valid_keys: data = set(data) dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data) @@ -843,11 +844,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): # If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead. # Not a docstring so it doesn't get grabbed by the options system. - default: typing.List[typing.Any] = [] + default: typing.Union[typing.List[typing.Any], typing.Tuple[typing.Any, ...]] = () supports_weighting = False - def __init__(self, value: typing.List[typing.Any]): - self.value = deepcopy(value) + def __init__(self, value: typing.Iterable[str]): + self.value = list(deepcopy(value)) super(OptionList, self).__init__() @classmethod @@ -856,7 +857,7 @@ def from_text(cls, text: str): @classmethod def from_any(cls, data: typing.Any): - if type(data) == list: + if is_iterable_of_str(data): cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) @@ -882,7 +883,7 @@ def from_text(cls, text: str): @classmethod def from_any(cls, data: typing.Any): - if isinstance(data, (list, set, frozenset)): + if is_iterable_of_str(data): cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) @@ -932,7 +933,7 @@ def __new__(mcs, bases: typing.Tuple[type, ...], attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty": for attr_type in attrs.values(): - assert not isinstance(attr_type, AssembleOptions),\ + assert not isinstance(attr_type, AssembleOptions), \ f"Options for {name} should be type hinted on the class, not assigned" return super().__new__(mcs, name, bases, attrs) @@ -1110,6 +1111,11 @@ class PerGameCommonOptions(CommonOptions): item_links: ItemLinks +@dataclass +class DeathLinkMixin: + death_link: DeathLink + + def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): import os diff --git a/Utils.py b/Utils.py index da2d837ad3a3..cea6405a38b4 100644 --- a/Utils.py +++ b/Utils.py @@ -19,6 +19,7 @@ from argparse import Namespace from settings import Settings, get_settings from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union +from typing_extensions import TypeGuard from yaml import load, load_all, dump try: @@ -966,3 +967,13 @@ def __bool__(self): def __len__(self): return sum(len(iterable) for iterable in self.iterable) + + +def is_iterable_of_str(obj: object) -> TypeGuard[typing.Iterable[str]]: + """ but not a `str` (because technically, `str` is `Iterable[str]`) """ + if isinstance(obj, str): + return False + if not isinstance(obj, typing.Iterable): + return False + obj_it: typing.Iterable[object] = obj + return all(isinstance(v, str) for v in obj_it) diff --git a/docs/settings api.md b/docs/settings api.md index f9cbe5e021cc..41023879adf8 100644 --- a/docs/settings api.md +++ b/docs/settings api.md @@ -121,6 +121,10 @@ Path to a single file. Automatically resolves as user_path: Source folder or AP install path on Windows. ~/Archipelago for the AppImage. Will open a file browser if the file is missing when in GUI mode. +If the file is used in the world's `generate_output`, make sure to add a `stage_assert_generate` that checks if the +file is available, otherwise generation may fail at the very end. +See also [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md#generation). + #### class method validate(cls, path: str) Override this and raise ValueError if validation fails. diff --git a/kvui.py b/kvui.py index 22e179d5be94..5e1b0fc03048 100644 --- a/kvui.py +++ b/kvui.py @@ -38,11 +38,13 @@ from kivy.factory import Factory from kivy.properties import BooleanProperty, ObjectProperty from kivy.metrics import dp +from kivy.effects.scroll import ScrollEffect from kivy.uix.widget import Widget from kivy.uix.button import Button from kivy.uix.gridlayout import GridLayout from kivy.uix.layout import Layout from kivy.uix.textinput import TextInput +from kivy.uix.scrollview import ScrollView from kivy.uix.recycleview import RecycleView from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem from kivy.uix.boxlayout import BoxLayout @@ -118,6 +120,17 @@ class ServerToolTip(ToolTip): pass +class ScrollBox(ScrollView): + layout: BoxLayout + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.layout = BoxLayout(size_hint_y=None) + self.layout.bind(minimum_height=self.layout.setter("height")) + self.add_widget(self.layout) + self.effect_cls = ScrollEffect + + class HovererableLabel(HoverBehavior, Label): pass diff --git a/requirements.txt b/requirements.txt index e2ccb67c18d4..9531e3058e8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ certifi>=2023.11.17 cython>=3.0.8 cymem>=2.0.8 orjson>=3.9.10 +typing-extensions>=4.7.0 diff --git a/setup.py b/setup.py index 272e6de0be27..3f9a7f0ba63f 100644 --- a/setup.py +++ b/setup.py @@ -80,7 +80,6 @@ "Super Mario 64", "VVVVVV", "Wargroove", - "Zillion", } # LogicMixin is broken before 3.10 import revamp diff --git a/typings/kivy/graphics/texture.pyi b/typings/kivy/graphics/texture.pyi index 19e03aad69dd..ca643b1cada5 100644 --- a/typings/kivy/graphics/texture.pyi +++ b/typings/kivy/graphics/texture.pyi @@ -10,4 +10,4 @@ class FillType_Drawable: class Texture: - pass + size: FillType_Vec diff --git a/worlds/Files.py b/worlds/Files.py index 336a3090937b..f46b9cba7a7c 100644 --- a/worlds/Files.py +++ b/worlds/Files.py @@ -41,6 +41,13 @@ def get_handler(file: str) -> Optional[AutoPatchRegister]: current_patch_version: int = 5 +class InvalidDataError(Exception): + """ + Since games can override `read_contents` in APContainer, + this is to report problems in that process. + """ + + class APContainer: """A zipfile containing at least archipelago.json""" version: int = current_patch_version @@ -89,7 +96,15 @@ def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None: with zipfile.ZipFile(zip_file, "r") as zf: if file: self.path = zf.filename - self.read_contents(zf) + try: + self.read_contents(zf) + except Exception as e: + message = "" + if len(e.args): + arg0 = e.args[0] + if isinstance(arg0, str): + message = f"{arg0} - " + raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None: with opened_zipfile.open("archipelago.json", "r") as f: diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index 7cf99be9e454..b639edfd20c1 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -153,9 +153,9 @@ class OpenPyramid(Choice): def to_bool(self, world: MultiWorld, player: int) -> bool: if self.value == self.option_goal: - return world.goal[player] in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} + return world.goal[player].current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} elif self.value == self.option_auto: - return world.goal[player] in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} \ + return world.goal[player].current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} \ and (world.entrance_shuffle[player] in {'vanilla', 'dungeons_simple', 'dungeons_full', 'dungeons_crossed'} or not world.shuffle_ganon) elif self.value == self.option_open: diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index 64a385a18587..1d548d8fdb53 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -176,6 +176,9 @@ def push_shop_inventories(multiworld): get_price(multiworld, location.shop.inventory[location.shop_slot], location.player, location.shop_price_type)[1]) + for world in multiworld.get_game_worlds("A Link to the Past"): + world.pushed_shop_inventories.set() + def create_shops(multiworld, player: int): @@ -451,15 +454,15 @@ def get_price(multiworld, item, player: int, price_type=None): if multiworld.randomize_shop_prices[player]: adjust = 2 if price < 100 else 5 price = int((price / adjust) * (0.5 + multiworld.random.random() * 1.5)) * adjust - multiworld.random.shuffle(price_types) + multiworld.per_slot_randoms[player].shuffle(price_types) for p_type in price_types: if any(x in item['item'] for x in price_blacklist[p_type]): continue return p_type, price_chart[p_type](price, diff) else: # This is an AP location and the price will be adjusted after an item is shuffled into it - p_type = multiworld.random.choice(price_types) - return p_type, price_chart[p_type](min(int(multiworld.random.randint(8, 56) + p_type = multiworld.per_slot_randoms[player].choice(price_types) + return p_type, price_chart[p_type](min(int(multiworld.per_slot_randoms[player].randint(8, 56) * multiworld.shop_price_modifier[player] / 100) * 5, 9999), diff) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 7a2664b3f4bc..a7ade61c9e33 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -256,6 +256,7 @@ def __init__(self, *args, **kwargs): self.dungeon_local_item_names = set() self.dungeon_specific_item_names = set() self.rom_name_available_event = threading.Event() + self.pushed_shop_inventories = threading.Event() self.has_progressive_bows = False self.dungeons = {} self.waterfall_fairy_bottle_fill = "Bottle" @@ -508,8 +509,8 @@ def stage_pre_fill(cls, world): fill_dungeons_restrictive(world) @classmethod - def stage_post_fill(cls, world): - push_shop_inventories(world) + def stage_generate_output(cls, multiworld, output_directory): + push_shop_inventories(multiworld) @property def use_enemizer(self) -> bool: @@ -523,6 +524,9 @@ def use_enemizer(self) -> bool: def generate_output(self, output_directory: str): multiworld = self.multiworld player = self.player + + self.pushed_shop_inventories.wait() + try: use_enemizer = self.use_enemizer diff --git a/worlds/alttp/test/options/TestOpenPyramid.py b/worlds/alttp/test/options/TestOpenPyramid.py index c66eb2ee98ec..895ecb95a949 100644 --- a/worlds/alttp/test/options/TestOpenPyramid.py +++ b/worlds/alttp/test/options/TestOpenPyramid.py @@ -23,7 +23,7 @@ class GoalPyramidTest(PyramidTestBase): } def testCrystalsGoalAccess(self): - self.multiworld.goal[1] = "crystals" + self.multiworld.goal[1].value = 1 # crystals self.assertFalse(self.can_reach_entrance("Pyramid Hole")) self.collect_by_name(["Hammer", "Progressive Glove", "Moon Pearl"]) self.assertTrue(self.can_reach_entrance("Pyramid Hole")) diff --git a/worlds/ff1/data/locations.json b/worlds/ff1/data/locations.json index 9771d51de088..2f465a78970e 100644 --- a/worlds/ff1/data/locations.json +++ b/worlds/ff1/data/locations.json @@ -1,253 +1,253 @@ { - "Coneria1": 257, - "Coneria2": 258, - "ConeriaMajor": 259, - "Coneria4": 260, - "Coneria5": 261, - "Coneria6": 262, - "MatoyasCave1": 299, - "MatoyasCave3": 301, - "MatoyasCave2": 300, - "NorthwestCastle1": 273, - "NorthwestCastle3": 275, - "NorthwestCastle2": 274, - "ToFTopLeft1": 263, - "ToFBottomLeft": 265, - "ToFTopLeft2": 264, - "ToFRevisited6": 509, - "ToFRevisited4": 507, - "ToFRMasmune": 504, - "ToFRevisited5": 508, - "ToFRevisited3": 506, - "ToFRevisited2": 505, - "ToFRevisited7": 510, - "ToFTopRight1": 267, - "ToFTopRight2": 268, - "ToFBottomRight": 266, - "IceCave15": 377, - "IceCave16": 378, - "IceCave9": 371, - "IceCave11": 373, - "IceCave10": 372, - "IceCave12": 374, - "IceCave13": 375, - "IceCave14": 376, - "IceCave1": 363, - "IceCave2": 364, - "IceCave3": 365, - "IceCave4": 366, - "IceCave5": 367, - "IceCaveMajor": 370, - "IceCave7": 369, - "IceCave6": 368, - "Elfland1": 269, - "Elfland2": 270, - "Elfland3": 271, - "Elfland4": 272, - "Ordeals5": 383, - "Ordeals6": 384, - "Ordeals7": 385, - "Ordeals1": 379, - "Ordeals2": 380, - "Ordeals3": 381, - "Ordeals4": 382, - "OrdealsMajor": 387, - "Ordeals8": 386, - "SeaShrine7": 411, - "SeaShrine8": 412, - "SeaShrine9": 413, - "SeaShrine10": 414, - "SeaShrine1": 405, - "SeaShrine2": 406, - "SeaShrine3": 407, - "SeaShrine4": 408, - "SeaShrine5": 409, - "SeaShrine6": 410, - "SeaShrine13": 417, - "SeaShrine14": 418, - "SeaShrine11": 415, - "SeaShrine15": 419, - "SeaShrine16": 420, - "SeaShrineLocked": 421, - "SeaShrine18": 422, - "SeaShrine19": 423, - "SeaShrine20": 424, - "SeaShrine23": 427, - "SeaShrine21": 425, - "SeaShrine22": 426, - "SeaShrine24": 428, - "SeaShrine26": 430, - "SeaShrine28": 432, - "SeaShrine25": 429, - "SeaShrine30": 434, - "SeaShrine31": 435, - "SeaShrine27": 431, - "SeaShrine29": 433, - "SeaShrineMajor": 436, - "SeaShrine12": 416, - "DwarfCave3": 291, - "DwarfCave4": 292, - "DwarfCave6": 294, - "DwarfCave7": 295, - "DwarfCave5": 293, - "DwarfCave8": 296, - "DwarfCave9": 297, - "DwarfCave10": 298, - "DwarfCave1": 289, - "DwarfCave2": 290, - "Waterfall1": 437, - "Waterfall2": 438, - "Waterfall3": 439, - "Waterfall4": 440, - "Waterfall5": 441, - "Waterfall6": 442, - "MirageTower5": 456, - "MirageTower16": 467, - "MirageTower17": 468, - "MirageTower15": 466, - "MirageTower18": 469, - "MirageTower14": 465, - "SkyPalace1": 470, - "SkyPalace2": 471, - "SkyPalace3": 472, - "SkyPalace4": 473, - "SkyPalace18": 487, - "SkyPalace19": 488, - "SkyPalace16": 485, - "SkyPalaceMajor": 489, - "SkyPalace17": 486, - "SkyPalace22": 491, - "SkyPalace21": 490, - "SkyPalace23": 492, - "SkyPalace24": 493, - "SkyPalace31": 500, - "SkyPalace32": 501, - "SkyPalace33": 502, - "SkyPalace34": 503, - "SkyPalace29": 498, - "SkyPalace26": 495, - "SkyPalace25": 494, - "SkyPalace28": 497, - "SkyPalace27": 496, - "SkyPalace30": 499, - "SkyPalace14": 483, - "SkyPalace11": 480, - "SkyPalace12": 481, - "SkyPalace13": 482, - "SkyPalace15": 484, - "SkyPalace10": 479, - "SkyPalace5": 474, - "SkyPalace6": 475, - "SkyPalace7": 476, - "SkyPalace8": 477, - "SkyPalace9": 478, - "MirageTower9": 460, - "MirageTower13": 464, - "MirageTower10": 461, - "MirageTower12": 463, - "MirageTower11": 462, - "MirageTower1": 452, - "MirageTower2": 453, - "MirageTower4": 455, - "MirageTower3": 454, - "MirageTower8": 459, - "MirageTower7": 458, - "MirageTower6": 457, - "Volcano30": 359, - "Volcano32": 361, - "Volcano31": 360, - "Volcano28": 357, - "Volcano29": 358, - "Volcano21": 350, - "Volcano20": 349, - "Volcano24": 353, - "Volcano19": 348, - "Volcano25": 354, - "VolcanoMajor": 362, - "Volcano26": 355, - "Volcano27": 356, - "Volcano22": 351, - "Volcano23": 352, - "Volcano1": 330, - "Volcano9": 338, - "Volcano2": 331, - "Volcano10": 339, - "Volcano3": 332, - "Volcano8": 337, - "Volcano4": 333, - "Volcano13": 342, - "Volcano11": 340, - "Volcano7": 336, - "Volcano6": 335, - "Volcano5": 334, - "Volcano14": 343, - "Volcano12": 341, - "Volcano15": 344, - "Volcano18": 347, - "Volcano17": 346, - "Volcano16": 345, - "MarshCave6": 281, - "MarshCave5": 280, - "MarshCave7": 282, - "MarshCave8": 283, - "MarshCave10": 285, - "MarshCave2": 277, - "MarshCave11": 286, - "MarshCave3": 278, - "MarshCaveMajor": 284, - "MarshCave12": 287, - "MarshCave4": 279, - "MarshCave1": 276, - "MarshCave13": 288, - "TitansTunnel1": 326, - "TitansTunnel2": 327, - "TitansTunnel3": 328, - "TitansTunnel4": 329, - "EarthCave1": 302, - "EarthCave2": 303, - "EarthCave5": 306, - "EarthCave3": 304, - "EarthCave4": 305, - "EarthCave9": 310, - "EarthCave10": 311, - "EarthCave11": 312, - "EarthCave6": 307, - "EarthCave7": 308, - "EarthCave12": 313, - "EarthCaveMajor": 317, - "EarthCave19": 320, - "EarthCave17": 318, - "EarthCave18": 319, - "EarthCave20": 321, - "EarthCave24": 325, - "EarthCave21": 322, - "EarthCave22": 323, - "EarthCave23": 324, - "EarthCave13": 314, - "EarthCave15": 316, - "EarthCave14": 315, - "EarthCave8": 309, - "Cardia11": 398, - "Cardia9": 396, - "Cardia10": 397, - "Cardia6": 393, - "Cardia8": 395, - "Cardia7": 394, - "Cardia13": 400, - "Cardia12": 399, - "Cardia4": 391, - "Cardia5": 392, - "Cardia3": 390, - "Cardia1": 388, - "Cardia2": 389, - "CaravanShop": 767, + "Matoya's Cave - Chest 1": 299, + "Matoya's Cave - Chest 2": 301, + "Matoya's Cave - Chest 3": 300, + "Dwarf Cave - Entrance 1": 289, + "Dwarf Cave - Entrance 2": 290, + "Dwarf Cave - Treasury 1": 291, + "Dwarf Cave - Treasury 2": 292, + "Dwarf Cave - Treasury 3": 295, + "Dwarf Cave - Treasury 4": 293, + "Dwarf Cave - Treasury 5": 294, + "Dwarf Cave - Treasury 6": 296, + "Dwarf Cave - Treasury 7": 297, + "Dwarf Cave - Treasury 8": 298, + "Coneria Castle - Treasury 1": 257, + "Coneria Castle - Treasury 2": 258, + "Coneria Castle - Treasury 3": 260, + "Coneria Castle - Treasury 4": 261, + "Coneria Castle - Treasury 5": 262, + "Coneria Castle - Treasury Major": 259, + "Elf Castle - Treasury 1": 269, + "Elf Castle - Treasury 2": 270, + "Elf Castle - Treasury 3": 271, + "Elf Castle - Treasury 4": 272, + "Northwest Castle - Treasury 1": 273, + "Northwest Castle - Treasury 2": 275, + "Northwest Castle - Treasury 3": 274, + "Titan's Tunnel - Chest 1": 327, + "Titan's Tunnel - Chest 2": 328, + "Titan's Tunnel - Chest 3": 329, + "Titan's Tunnel - Major": 326, + "Cardia Grass Island - Entrance": 398, + "Cardia Grass Island - Duo Room 1": 396, + "Cardia Grass Island - Duo Rooom 2": 397, + "Cardia Swamp Island - Chest 1": 393, + "Cardia Swamp Island - Chest 2": 395, + "Cardia Swamp Island - Chest 3": 394, + "Cardia Forest Island - Entrance 1": 389, + "Cardia Forest Island - Entrance 2": 388, + "Cardia Forest Island - Entrance 3": 390, + "Cardia Forest Island - Incentive 1": 400, + "Cardia Forest Island - Incentive 2": 399, + "Cardia Forest Island - Incentive 3": 392, + "Cardia Forest Island - Incentive Major": 391, + "Temple of Fiends - Unlocked Single": 265, + "Temple of Fiends - Unlocked Duo 1": 263, + "Temple of Fiends - Unlocked Duo 2": 264, + "Temple of Fiends - Locked Single": 266, + "Temple of Fiends - Locked Duo 1": 267, + "Temple of Fiends - Locked Duo 2": 268, + "Marsh Cave Top (B1) - Single": 283, + "Marsh Cave Top (B1) - Corner": 282, + "Marsh Cave Top (B1) - Duo 1": 281, + "Marsh Cave Top (B1) - Duo 2": 280, + "Marsh Cave Bottom (B2) - Distant": 276, + "Marsh Cave Bottom (B2) - Tetris-Z First": 277, + "Marsh Cave Bottom (B2) - Tetris-Z Middle 1": 278, + "Marsh Cave Bottom (B2) - Tetris-Z Middle 2": 285, + "Marsh Cave Bottom (B2) - Tetris-Z Incentive": 284, + "Marsh Cave Bottom (B2) - Tetris-Z Last": 279, + "Marsh Cave Bottom (B2) - Locked Corner": 286, + "Marsh Cave Bottom (B2) - Locked Middle": 287, + "Marsh Cave Bottom (B2) - Locked Incentive": 288, + "Earth Cave Giant's Floor (B1) - Single": 306, + "Earth Cave Giant's Floor (B1) - Appendix 1": 302, + "Earth Cave Giant's Floor (B1) - Appendix 2": 303, + "Earth Cave Giant's Floor (B1) - Side Path 1": 304, + "Earth Cave Giant's Floor (B1) - Side Path 2": 305, + "Earth Cave (B2) - Side Room 1": 307, + "Earth Cave (B2) - Side Room 2": 308, + "Earth Cave (B2) - Side Room 3": 309, + "Earth Cave (B2) - Guarded 1": 310, + "Earth Cave (B2) - Guarded 2": 311, + "Earth Cave (B2) - Guarded 3": 312, + "Earth Cave Vampire Floor (B3) - Side Room": 315, + "Earth Cave Vampire Floor (B3) - TFC": 316, + "Earth Cave Vampire Floor (B3) - Asher Trunk": 314, + "Earth Cave Vampire Floor (B3) - Vampire's Closet": 313, + "Earth Cave Vampire Floor (B3) - Incentive": 317, + "Earth Cave Rod Locked Floor (B4) - Armory 1": 321, + "Earth Cave Rod Locked Floor (B4) - Armory 2": 322, + "Earth Cave Rod Locked Floor (B4) - Armory 3": 325, + "Earth Cave Rod Locked Floor (B4) - Armory 4": 323, + "Earth Cave Rod Locked Floor (B4) - Armory 5": 324, + "Earth Cave Rod Locked Floor (B4) - Lich's Closet 1": 318, + "Earth Cave Rod Locked Floor (B4) - Lich's Closet 2": 319, + "Earth Cave Rod Locked Floor (B4) - Lich's Closet 3": 320, + "Gurgu Volcano Armory Floor (B2) - Guarded": 346, + "Gurgu Volcano Armory Floor (B2) - Center": 347, + "Gurgu Volcano Armory Floor (B2) - Hairpins": 344, + "Gurgu Volcano Armory Floor (B2) - Shortpins": 345, + "Gurgu Volcano Armory Floor (B2) - Vertpins 1": 342, + "Gurgu Volcano Armory Floor (B2) - Vertpins 2": 343, + "Gurgu Volcano Armory Floor (B2) - Armory 1": 338, + "Gurgu Volcano Armory Floor (B2) - Armory 2": 330, + "Gurgu Volcano Armory Floor (B2) - Armory 3": 331, + "Gurgu Volcano Armory Floor (B2) - Armory 4": 337, + "Gurgu Volcano Armory Floor (B2) - Armory 5": 335, + "Gurgu Volcano Armory Floor (B2) - Armory 6": 332, + "Gurgu Volcano Armory Floor (B2) - Armory 7": 333, + "Gurgu Volcano Armory Floor (B2) - Armory 8": 334, + "Gurgu Volcano Armory Floor (B2) - Armory 9": 341, + "Gurgu Volcano Armory Floor (B2) - Armory 10": 336, + "Gurgu Volcano Armory Floor (B2) - Armory 11": 340, + "Gurgu Volcano Armory Floor (B2) - Armory 12": 339, + "Gurgu Volcano Agama Floor (B4) - Entrance 1": 349, + "Gurgu Volcano Agama Floor (B4) - Entrance 2": 348, + "Gurgu Volcano Agama Floor (B4) - First Greed": 350, + "Gurgu Volcano Agama Floor (B4) - Worm Room 1": 361, + "Gurgu Volcano Agama Floor (B4) - Worm Room 2": 359, + "Gurgu Volcano Agama Floor (B4) - Worm Room 3": 360, + "Gurgu Volcano Agama Floor (B4) - Worm Room 4": 357, + "Gurgu Volcano Agama Floor (B4) - Worm Room 5": 358, + "Gurgu Volcano Agama Floor (B4) - Second Greed 1": 353, + "Gurgu Volcano Agama Floor (B4) - Second Greed 2": 354, + "Gurgu Volcano Agama Floor (B4) - Side Room 1": 355, + "Gurgu Volcano Agama Floor (B4) - Side Room 2": 356, + "Gurgu Volcano Agama Floor (B4) - Grind Room 1": 351, + "Gurgu Volcano Agama Floor (B4) - Grind Room 2": 352, + "Gurgu Volcano Kary Floor (B5) - Incentive": 362, + "Ice Cave Incentive Floor (B2) - Chest 1": 368, + "Ice Cave Incentive Floor (B2) - Chest 2": 369, + "Ice Cave Incentive Floor (B2) - Major": 370, + "Ice Cave Bottom (B3) - IceD Room 1": 377, + "Ice Cave Bottom (B3) - IceD Room 2": 378, + "Ice Cave Bottom (B3) - Six-Pack 1": 371, + "Ice Cave Bottom (B3) - Six-Pack 2": 372, + "Ice Cave Bottom (B3) - Six-Pack 3": 375, + "Ice Cave Bottom (B3) - Six-Pack 4": 373, + "Ice Cave Bottom (B3) - Six-Pack 5": 374, + "Ice Cave Bottom (B3) - Six-Pack 6": 376, + "Ice Cave Exit Floor (B1) - Greeds Checks 1": 363, + "Ice Cave Exit Floor (B1) - Greeds Checks 2": 364, + "Ice Cave Exit Floor (B1) - Drop Room 1": 365, + "Ice Cave Exit Floor (B1) - Drop Room 2": 366, + "Ice Cave Exit Floor (B1) - Drop Room 3": 367, + "Castle of Ordeals Top Floor (3F) - Single": 386, + "Castle of Ordeals Top Floor (3F) - Three-Pack 1": 383, + "Castle of Ordeals Top Floor (3F) - Three-Pack 2": 384, + "Castle of Ordeals Top Floor (3F) - Three-Pack 3": 385, + "Castle of Ordeals Top Floor (3F) - Four-Pack 1": 379, + "Castle of Ordeals Top Floor (3F) - Four-Pack 2": 380, + "Castle of Ordeals Top Floor (3F) - Four-Pack 3": 381, + "Castle of Ordeals Top Floor (3F) - Four-Pack 4": 382, + "Castle of Ordeals Top Floor (3F) - Incentive": 387, + "Sea Shrine Split Floor (B3) - Kraken Side": 415, + "Sea Shrine Split Floor (B3) - Mermaid Side": 416, + "Sea Shrine TFC Floor (B2) - TFC": 421, + "Sea Shrine TFC Floor (B2) - TFC North": 420, + "Sea Shrine TFC Floor (B2) - Side Corner": 419, + "Sea Shrine TFC Floor (B2) - First Greed": 422, + "Sea Shrine TFC Floor (B2) - Second Greed": 423, + "Sea Shrine Mermaids (B1) - Passby": 427, + "Sea Shrine Mermaids (B1) - Bubbles 1": 428, + "Sea Shrine Mermaids (B1) - Bubbles 2": 429, + "Sea Shrine Mermaids (B1) - Incentive 1": 434, + "Sea Shrine Mermaids (B1) - Incentive 2": 435, + "Sea Shrine Mermaids (B1) - Incentive Major": 436, + "Sea Shrine Mermaids (B1) - Entrance 1": 424, + "Sea Shrine Mermaids (B1) - Entrance 2": 425, + "Sea Shrine Mermaids (B1) - Entrance 3": 426, + "Sea Shrine Mermaids (B1) - Four-Corner First": 430, + "Sea Shrine Mermaids (B1) - Four-Corner Second": 431, + "Sea Shrine Mermaids (B1) - Four-Corner Third": 432, + "Sea Shrine Mermaids (B1) - Four-Corner Fourth": 433, + "Sea Shrine Greed Floor (B3) - Chest 1": 418, + "Sea Shrine Greed Floor (B3) - Chest 2": 417, + "Sea Shrine Sharknado Floor (B4) - Dengbait 1": 409, + "Sea Shrine Sharknado Floor (B4) - Dengbait 2": 410, + "Sea Shrine Sharknado Floor (B4) - Side Corner 1": 411, + "Sea Shrine Sharknado Floor (B4) - Side Corner 2": 412, + "Sea Shrine Sharknado Floor (B4) - Side Corner 3": 413, + "Sea Shrine Sharknado Floor (B4) - Exit": 414, + "Sea Shrine Sharknado Floor (B4) - Greed Room 1": 405, + "Sea Shrine Sharknado Floor (B4) - Greed Room 2": 406, + "Sea Shrine Sharknado Floor (B4) - Greed Room 3": 407, + "Sea Shrine Sharknado Floor (B4) - Greed Room 4": 408, + "Waterfall Cave - Chest 1": 437, + "Waterfall Cave - Chest 2": 438, + "Waterfall Cave - Chest 3": 439, + "Waterfall Cave - Chest 4": 440, + "Waterfall Cave - Chest 5": 441, + "Waterfall Cave - Chest 6": 442, + "Mirage Tower (1F) - Chest 1": 456, + "Mirage Tower (1F) - Chest 2": 452, + "Mirage Tower (1F) - Chest 3": 453, + "Mirage Tower (1F) - Chest 4": 455, + "Mirage Tower (1F) - Chest 5": 454, + "Mirage Tower (1F) - Chest 6": 459, + "Mirage Tower (1F) - Chest 7": 457, + "Mirage Tower (1F) - Chest 8": 458, + "Mirage Tower (2F) - Lesser 1": 469, + "Mirage Tower (2F) - Lesser 2": 468, + "Mirage Tower (2F) - Lesser 3": 467, + "Mirage Tower (2F) - Lesser 4": 466, + "Mirage Tower (2F) - Lesser 5": 465, + "Mirage Tower (2F) - Greater 1": 460, + "Mirage Tower (2F) - Greater 2": 461, + "Mirage Tower (2F) - Greater 3": 462, + "Mirage Tower (2F) - Greater 4": 463, + "Mirage Tower (2F) - Greater 5": 464, + "Sky Fortress Plus (1F) - Solo": 479, + "Sky Fortress Plus (1F) - Five-Pack 1": 474, + "Sky Fortress Plus (1F) - Five-Pack 2": 475, + "Sky Fortress Plus (1F) - Five-Pack 3": 476, + "Sky Fortress Plus (1F) - Five-Pack 4": 477, + "Sky Fortress Plus (1F) - Five-Pack 5": 478, + "Sky Fortress Plus (1F) - Four-Pack 1": 470, + "Sky Fortress Plus (1F) - Four-Pack 2": 471, + "Sky Fortress Plus (1F) - Four-Pack 3": 472, + "Sky Fortress Plus (1F) - Four-Pack 4": 473, + "Sky Fortress Spider (2F) - Cheap Room 1": 485, + "Sky Fortress Spider (2F) - Cheap Room 2": 486, + "Sky Fortress Spider (2F) - Vault 1": 487, + "Sky Fortress Spider (2F) - Vault 2": 488, + "Sky Fortress Spider (2F) - Incentive": 489, + "Sky Fortress Spider (2F) - Gauntlet Room": 483, + "Sky Fortress Spider (2F) - Ribbon Room 1": 482, + "Sky Fortress Spider (2F) - Ribbon Room 2": 484, + "Sky Fortress Spider (2F) - Wardrobe 1": 480, + "Sky Fortress Spider (2F) - Wardrobe 2": 481, + "Sky Fortress Provides (3F) - Six-Pack 1": 498, + "Sky Fortress Provides (3F) - Six-Pack 2": 495, + "Sky Fortress Provides (3F) - Six-Pack 3": 494, + "Sky Fortress Provides (3F) - Six-Pack 4": 497, + "Sky Fortress Provides (3F) - Six-Pack 5": 496, + "Sky Fortress Provides (3F) - Six-Pack 6": 499, + "Sky Fortress Provides (3F) - CC's Gambit 1": 500, + "Sky Fortress Provides (3F) - CC's Gambit 2": 501, + "Sky Fortress Provides (3F) - CC's Gambit 3": 502, + "Sky Fortress Provides (3F) - CC's Gambit 4": 503, + "Sky Fortress Provides (3F) - Greed 1": 491, + "Sky Fortress Provides (3F) - Greed 2": 490, + "Sky Fortress Provides (3F) - Greed 3": 492, + "Sky Fortress Provides (3F) - Greed 4": 493, + "Temple of Fiends Revisited (3F) - Validation 1": 509, + "Temple of Fiends Revisited (3F) - Validation 2": 510, + "Temple of Fiends Revisited Kary Floor (6F) - Greed Checks 1": 507, + "Temple of Fiends Revisited Kary Floor (6F) - Greed Checks 2": 508, + "Temple of Fiends Revisited Kary Floor (6F) - Katana Chest": 506, + "Temple of Fiends Revisited Kary Floor (6F) - Vault": 505, + "Temple of Fiends Revisited Tiamat Floor (8F) - Masamune Chest": 504, + "Shop Item": 767, "King": 513, - "Princess2": 530, + "Princess": 530, "Matoya": 522, "Astos": 519, "Bikke": 516, - "CanoeSage": 533, - "ElfPrince": 518, + "Canoe Sage": 533, + "Elf Prince": 518, "Nerrick": 520, "Smith": 521, "CubeBot": 529, diff --git a/worlds/ffmq/Regions.py b/worlds/ffmq/Regions.py index 61f70864c0b4..8b83c88e72c9 100644 --- a/worlds/ffmq/Regions.py +++ b/worlds/ffmq/Regions.py @@ -220,15 +220,12 @@ def stage_set_rules(multiworld): for player in no_enemies_players: for location in vendor_locations: if multiworld.accessibility[player] == "locations": - print("exclude") multiworld.get_location(location, player).progress_type = LocationProgressType.EXCLUDED else: - print("unreachable") multiworld.get_location(location, player).access_rule = lambda state: False else: # There are not enough junk items to fill non-minimal players' vendors. Just set an item rule not allowing - # advancement items so that useful items can be placed. - print("no advancement") + # advancement items so that useful items can be placed for player in no_enemies_players: for location in vendor_locations: multiworld.get_location(location, player).item_rule = lambda item: not item.advancement diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index ac5e1aa50750..c434351e9493 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -41,12 +41,12 @@ def forbid(sender: int, receiver: int, items: typing.Set[str]): forbid_data[sender][receiver].update(items) for receiving_player in world.player_ids: - local_items: typing.Set[str] = world.local_items[receiving_player].value + local_items: typing.Set[str] = world.worlds[receiving_player].options.local_items.value if local_items: for sending_player in world.player_ids: if receiving_player != sending_player: forbid(sending_player, receiving_player, local_items) - non_local_items: typing.Set[str] = world.non_local_items[receiving_player].value + non_local_items: typing.Set[str] = world.worlds[receiving_player].options.non_local_items.value if non_local_items: forbid(receiving_player, receiving_player, non_local_items) diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 181cc053222d..6742dffd30c3 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -276,6 +276,11 @@ def create_items(self) -> None: # Properly fill locations within dungeon location.dungeon = r.dungeon_index + # For now, special case first item + FORCE_START_ITEM = True + if FORCE_START_ITEM: + self.force_start_item() + def force_start_item(self): start_loc = self.multiworld.get_location("Tarin's Gift (Mabe Village)", self.player) if not start_loc.item: @@ -287,17 +292,12 @@ def force_start_item(self): start_item = self.multiworld.itempool.pop(index) start_loc.place_locked_item(start_item) - def get_pre_fill_items(self): return self.pre_fill_items def pre_fill(self) -> None: allowed_locations_by_item = {} - # For now, special case first item - FORCE_START_ITEM = True - if FORCE_START_ITEM: - self.force_start_item() # Set up filter rules diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index 1a149f2db9f0..f72e63c1427e 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -112,6 +112,7 @@ HI: id: Entry Room/Panel_hi_hi tag: midwhite + check: True HIDDEN: id: Entry Room/Panel_hidden_hidden tag: midwhite diff --git a/worlds/lingo/player_logic.py b/worlds/lingo/player_logic.py index 0ae303518cf1..3a6eedfe0ae1 100644 --- a/worlds/lingo/player_logic.py +++ b/worlds/lingo/player_logic.py @@ -248,30 +248,44 @@ def __init__(self, world: "LingoWorld"): "kind of logic error.") if door_shuffle != ShuffleDoors.option_none and location_classification != LocationClassification.insanity \ - and not early_color_hallways: - # If shuffle doors is on, force a useful item onto the HI panel. This may not necessarily get you out of BK, - # but the goal is to allow you to reach at least one more check. The non-painting ones are hardcoded right - # now. We only allow the entrance to the Pilgrim Room if color shuffle is off, because otherwise there are - # no extra checks in there. We only include the entrance to the Rhyme Room when color shuffle is off and - # door shuffle is on simple, because otherwise there are no extra checks in there. + and not early_color_hallways and world.multiworld.players > 1: + # Under the combination of door shuffle, normal location checks, and no early color hallways, sphere 1 is + # only three checks. In a multiplayer situation, this can be frustrating for the player because they are + # more likely to be stuck in the starting room for a long time. To remedy this, we will force a useful item + # onto the GOOD LUCK check under these circumstances. The goal is to expand sphere 1 to at least four + # checks (and likely more than that). + # + # Note: A very low LEVEL 2 requirement would naturally expand sphere 1 to four checks, but this is a very + # uncommon configuration, so we will ignore it and force a good item anyway. + + # Starting Room - Back Right Door gives access to OPEN and DEAD END. + # Starting Room - Exit Door gives access to OPEN and TRACE. good_item_options: List[str] = ["Starting Room - Back Right Door", "Second Room - Exit Door"] if not color_shuffle: + # HOT CRUST and THIS. good_item_options.append("Pilgrim Room - Sun Painting") - if door_shuffle == ShuffleDoors.option_simple: - good_item_options += ["Welcome Back Doors"] + if door_shuffle == ShuffleDoors.option_simple: + # WELCOME BACK, CLOCKWISE, and DRAWL + RUNS. + good_item_options.append("Welcome Back Doors") + else: + # WELCOME BACK and CLOCKWISE. + good_item_options.append("Welcome Back Area - Shortcut to Starting Room") - if not color_shuffle: - good_item_options.append("Rhyme Room Doors") - else: - good_item_options += ["Welcome Back Area - Shortcut to Starting Room"] + if door_shuffle == ShuffleDoors.option_simple: + # Color hallways access (NOTE: reconsider when sunwarp shuffling exists). + good_item_options.append("Rhyme Room Doors") + # When painting shuffle is off, most Starting Room paintings give color hallways access. The Wondrous's + # painting does not, but it gives access to SHRINK and WELCOME BACK. for painting_obj in PAINTINGS_BY_ROOM["Starting Room"]: if not painting_obj.enter_only or painting_obj.required_door is None: continue # If painting shuffle is on, we only want to consider paintings that actually go somewhere. + # + # NOTE: This does not guarantee that there will be any checks on the other side. if painting_shuffle and painting_obj.id not in self.painting_mapping.keys(): continue diff --git a/worlds/lingo/test/TestDoors.py b/worlds/lingo/test/TestDoors.py index 49a0f9c49010..f496c5f5785a 100644 --- a/worlds/lingo/test/TestDoors.py +++ b/worlds/lingo/test/TestDoors.py @@ -8,8 +8,6 @@ class TestRequiredRoomLogic(LingoTestBase): } def test_pilgrim_first(self) -> None: - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("The Seeker", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Pilgrim Antechamber", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Pilgrim Room", "Region", self.player)) @@ -30,8 +28,6 @@ def test_pilgrim_first(self) -> None: self.assertTrue(self.can_reach_location("The Seeker - Achievement")) def test_hidden_first(self) -> None: - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("The Seeker", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Pilgrim Room", "Region", self.player)) self.assertFalse(self.can_reach_location("The Seeker - Achievement")) @@ -59,8 +55,6 @@ class TestRequiredDoorLogic(LingoTestBase): } def test_through_rhyme(self) -> None: - self.remove_forced_good_item() - self.assertFalse(self.can_reach_location("Rhyme Room - Circle/Looped Square Wall")) self.collect_by_name("Starting Room - Rhyme Room Entrance") @@ -70,8 +64,6 @@ def test_through_rhyme(self) -> None: self.assertTrue(self.can_reach_location("Rhyme Room - Circle/Looped Square Wall")) def test_through_hidden(self) -> None: - self.remove_forced_good_item() - self.assertFalse(self.can_reach_location("Rhyme Room - Circle/Looped Square Wall")) self.collect_by_name("Starting Room - Rhyme Room Entrance") @@ -91,8 +83,6 @@ class TestSimpleDoors(LingoTestBase): } def test_requirement(self): - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("Outside The Wanderer", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player)) diff --git a/worlds/lingo/test/TestOrangeTower.py b/worlds/lingo/test/TestOrangeTower.py index 9170de108ad0..7b0c3bb52518 100644 --- a/worlds/lingo/test/TestOrangeTower.py +++ b/worlds/lingo/test/TestOrangeTower.py @@ -8,8 +8,6 @@ class TestProgressiveOrangeTower(LingoTestBase): } def test_from_welcome_back(self) -> None: - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("Orange Tower First Floor", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Orange Tower Second Floor", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player)) @@ -85,8 +83,6 @@ def test_from_welcome_back(self) -> None: self.assertTrue(self.multiworld.state.can_reach("Orange Tower Seventh Floor", "Region", self.player)) def test_from_hub_room(self) -> None: - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("Orange Tower First Floor", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Orange Tower Second Floor", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player)) diff --git a/worlds/lingo/test/TestProgressive.py b/worlds/lingo/test/TestProgressive.py index 081d6743a5f2..e79fd6bc9087 100644 --- a/worlds/lingo/test/TestProgressive.py +++ b/worlds/lingo/test/TestProgressive.py @@ -7,8 +7,6 @@ class TestComplexProgressiveHallwayRoom(LingoTestBase): } def test_item(self): - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("Outside The Agreeable", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Hallway Room (2)", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Hallway Room (3)", "Region", self.player)) @@ -60,8 +58,6 @@ class TestSimpleHallwayRoom(LingoTestBase): } def test_item(self): - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("Outside The Agreeable", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Hallway Room (2)", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Hallway Room (3)", "Region", self.player)) @@ -90,8 +86,6 @@ class TestProgressiveArtGallery(LingoTestBase): } def test_item(self): - self.remove_forced_good_item() - self.assertFalse(self.multiworld.state.can_reach("Art Gallery", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Art Gallery (Second Floor)", "Region", self.player)) self.assertFalse(self.multiworld.state.can_reach("Art Gallery (Third Floor)", "Region", self.player)) diff --git a/worlds/lingo/test/__init__.py b/worlds/lingo/test/__init__.py index 7ff456d8fcc3..a4196de110db 100644 --- a/worlds/lingo/test/__init__.py +++ b/worlds/lingo/test/__init__.py @@ -6,12 +6,3 @@ class LingoTestBase(WorldTestBase): game = "Lingo" player: ClassVar[int] = 1 - - def world_setup(self, *args, **kwargs): - super().world_setup(*args, **kwargs) - - def remove_forced_good_item(self): - location = self.multiworld.get_location("Second Room - Good Luck", self.player) - self.remove(location.item) - self.multiworld.itempool.append(location.item) - self.multiworld.state.events.add(location) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 1da544bee70c..6984e215472a 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -3,7 +3,7 @@ from schema import And, Optional, Or, Schema -from Options import Accessibility, Choice, DeathLink, DefaultOnToggle, OptionDict, PerGameCommonOptions, Range, \ +from Options import Accessibility, Choice, DeathLinkMixin, DefaultOnToggle, OptionDict, PerGameCommonOptions, Range, \ StartInventoryPool, Toggle @@ -133,7 +133,7 @@ class PlannedShopPrices(OptionDict): @dataclass -class MessengerOptions(PerGameCommonOptions): +class MessengerOptions(DeathLinkMixin, PerGameCommonOptions): accessibility: MessengerAccessibility start_inventory: StartInventoryPool logic_level: Logic @@ -146,5 +146,3 @@ class MessengerOptions(PerGameCommonOptions): percent_seals_required: RequiredSeals shop_price: ShopPrices shop_price_plan: PlannedShopPrices - death_link: DeathLink - diff --git a/worlds/pokemon_emerald/docs/rom changes.md b/worlds/pokemon_emerald/docs/rom changes.md new file mode 100644 index 000000000000..9b189d08e76a --- /dev/null +++ b/worlds/pokemon_emerald/docs/rom changes.md @@ -0,0 +1,75 @@ +## QoL + +- The catch tutorial and cutscenes during your first visit to Petalburg are skipped +- The match call tutorial after you leave Devon Corp is skipped +- Cycling and running is allowed in every map (some exceptions like Fortree and Pacifidlog) +- When you run out of Repel steps, you'll be prompted to use another one if you have more in your bag +- Text is always rendered in its entirety on the first frame (instant text) +- With an option set, text will advance if A is held +- The message explaining that the trainer is about to send out a new pokemon is shortened to fit on two lines so that +you can still read the species when deciding whether to change pokemon +- The Pokemon Center Nurse dialogue is entirely removed except for the final text box +- When receiving TMs and HMs, the move that it teaches is consistently displayed in the "received item" message (by +default, certain ways of receiving items would only display the TM/HM number) +- The Pokedex starts in national mode +- The Oldale Pokemart sells Poke Balls at the start of the game +- Pauses during battles (e.g. the ~1 second pause at the start of a turn before an opponent uses a potion) are shorter +by 62.5% +- The sliding animation for trainers and wild pokemon at the start of a battle runs at double speed. +- Bag space was greatly expanded (there is room for one stack of every unique item in every pocket, plus a little bit +extra for some pockets) + - Save data format was changed as a result of this. Shrank some unused space and removed some multiplayer phrases from + the save data. + - Pretty much any code that checks for bag space is ignored or bypassed (this sounds dangerous, but with expanded bag + space you should pretty much never have a full bag unless you're trying to fill it up, and skipping those checks + greatly simplifies detecting when items are picked up) +- Pokemon are never disobedient +- When moving in the overworld, set the input priority based on the most recently pressed direction rather than by some +predetermined priority +- Shoal cave changes state every time you reload the map and is no longer tied to the RTC +- Increased safari zone steps from 500 to 50000 +- Trainers will not approach the player if the blind trainers option is set +- Changed trade evolutions to be possible without trading: + - Politoed: Use King's Rock in bag menu + - Alakazam: Level 37 + - Machamp: Level 37 + - Golem: Level 37 + - Slowking: Use King's Rock in bag menu + - Gengar: Level 37 + - Steelix: Use Metal Coat in bag menu + - Kingdra: Use Dragon Scale in bag menu + - Scizor: Use Metal Coat in bag menu + - Porygon2: Use Up-Grade in bag menu + - Milotic: Level 30 + - Huntail: Use Deep Sea Tooth in bag menu + - Gorebyss: Use Deep Sea Scale in bag menu + +## Game State Changes/Softlock Prevention + +- Mr. Briney never disappears or stops letting you use his ferry +- Prevent the player from flying or surfing until they have received the Pokedex +- The S.S. Tidal will be available at all times if you have the option enabled +- Some NPCs or tiles are removed on the creation of a new save file based on player options +- Ensured that every species has some damaging move by level 5 +- Route 115 may have strength boulders between the beach and cave entrance based on player options +- The Petalburg Gym is set up based on your player options rather than after the first 4 gyms +- The E4 guards will actually check all your badges (or gyms beaten based on your options) instead of just the Feather +Badge +- Steven cuts the conversation short in Granite Cave if you don't have the Letter +- Dock checks that you have the Devon Goods before asking you to deliver them (and thus opening the museum) +- Rydel gives you both bikes at the same time +- The man in Pacifidlog who gives you Frustration and Return will give you both at the same time, does not check +friendship first, and no longer has any behavior related to the RTC +- The woman who gives you the Soothe Bell in Slateport does not check friendship +- When trading the Scanner with Captain Stern, you will receive both the Deep Sea Tooth and Deep Sea Scale + +## Misc + +- You can no longer try to switch bikes in the bike shop +- The Seashore House only rewards you with 1 Soda Pop instead of 6 +- Many small changes that make it possible to swap single battles to double battles + - Includes some safeguards against two trainers seeing you and initiating a battle while one or both of them are + "single trainer double battles" +- Game now properly waits on vblank instead of spinning in a while loop +- Misc small changes to text for consistency +- Many bugfixes to the vanilla game code diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index de4f4e33dc87..e9341ec3b9de 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -115,7 +115,7 @@ def create_items(self): for i in range(item.count): subnautica_item = self.create_item(item.name) if item.name == "Neptune Launch Platform": - self.multiworld.get_location("Aurora - Captain Data Terminal", self.player).place_locked_item( + self.get_location("Aurora - Captain Data Terminal").place_locked_item( subnautica_item) else: pool.append(subnautica_item) @@ -128,7 +128,7 @@ def create_items(self): pool.append(self.create_item(name)) extras -= group_amount - for item_name in self.multiworld.random.sample( + for item_name in self.random.sample( # list of high-count important fragments as priority filler [ "Cyclops Engine Fragment", @@ -175,18 +175,6 @@ def create_item(self, name: str) -> SubnauticaItem: item_table[item_id].classification, item_id, player=self.player) - def create_region(self, name: str, region_locations=None, exits=None): - ret = Region(name, self.player, self.multiworld) - if region_locations: - for location in region_locations: - loc_id = self.location_name_to_id.get(location, None) - location = SubnauticaLocation(self.player, location, loc_id, ret) - ret.locations.append(location) - if exits: - for region_exit in exits: - ret.exits.append(Entrance(self.player, region_exit, ret)) - return ret - def get_filler_item_name(self) -> str: return item_table[self.multiworld.random.choice(items_by_type[ItemType.resource])].name diff --git a/worlds/tloz/Locations.py b/worlds/tloz/Locations.py index 3e46c4383373..5b30357c940c 100644 --- a/worlds/tloz/Locations.py +++ b/worlds/tloz/Locations.py @@ -105,6 +105,10 @@ "Level 7 Bomb Drop (Dodongos)", "Level 7 Rupee Drop (Goriyas North)" ] +gleeok_locations = [ + "Level 4 Boss", "Level 4 Triforce", "Level 8 Boss", "Level 8 Triforce" +] + floor_location_game_offsets_early = { "Level 1 Item (Bow)": 0x7F, "Level 1 Item (Boomerang)": 0x44, diff --git a/worlds/tloz/Rules.py b/worlds/tloz/Rules.py index b94002f25da2..f8b21bff712c 100644 --- a/worlds/tloz/Rules.py +++ b/worlds/tloz/Rules.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from worlds.generic.Rules import add_rule -from .Locations import food_locations, shop_locations +from .Locations import food_locations, shop_locations, gleeok_locations from .ItemPool import dangerous_weapon_locations from .Options import StartingPosition @@ -80,6 +80,10 @@ def set_rules(tloz_world: "TLoZWorld"): add_rule(world.get_location(location, player), lambda state: state.has("Food", player)) + for location in gleeok_locations: + add_rule(world.get_location(location, player), + lambda state: state.has_group("swords", player) or state.has("Magical Rod", player)) + add_rule(world.get_location("Level 8 Item (Magical Key)", player), lambda state: state.has("Bow", player) and state.has_group("arrows", player)) if options.ExpandedPool: diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index c38898b33d4e..e985dde353aa 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -44,10 +44,6 @@ class WitnessWorld(World): """ game = "The Witness" topology_present = False - - StaticWitnessLogic() - StaticWitnessLocations() - StaticWitnessItems() web = WitnessWebWorld() options_dataclass = TheWitnessOptions diff --git a/worlds/witness/items.py b/worlds/witness/items.py index 41bc3c1bb8da..6802fd2a21b5 100644 --- a/worlds/witness/items.py +++ b/worlds/witness/items.py @@ -176,9 +176,14 @@ def get_filler_items(self, quantity: int) -> Dict[str, int]: # Read trap configuration data. trap_weight = self._world.options.trap_percentage / 100 - filler_weight = 1 - trap_weight + trap_items = self._world.options.trap_weights.value + + if not sum(trap_items.values()): + trap_weight = 0 # Add filler items to the list. + filler_weight = 1 - trap_weight + filler_items: Dict[str, float] filler_items = {name: data.definition.weight if isinstance(data.definition, WeightedItemDefinition) else 1 for (name, data) in self.item_data.items() if data.definition.category is ItemCategory.FILLER} @@ -187,8 +192,6 @@ def get_filler_items(self, quantity: int) -> Dict[str, int]: # Add trap items. if trap_weight > 0: - trap_items = {name: data.definition.weight if isinstance(data.definition, WeightedItemDefinition) else 1 - for (name, data) in self.item_data.items() if data.definition.category is ItemCategory.TRAP} filler_items.update({name: base_weight * trap_weight / sum(trap_items.values()) for name, base_weight in trap_items.items() if base_weight > 0}) @@ -267,3 +270,6 @@ def get_progressive_item_ids_in_pool(self) -> Dict[int, List[int]]: output[item.ap_code] = [StaticWitnessItems.item_data[child_item].ap_code for child_item in item.definition.child_item_names] return output + + +StaticWitnessItems() diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index d38cf9025806..cd6d71f46911 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -569,3 +569,6 @@ def add_location_late(self, entity_name: str): entity_hex = StaticWitnessLogic.ENTITIES_BY_NAME[entity_name]["entity_hex"] self.CHECK_LOCATION_TABLE[entity_hex] = entity_name self.CHECK_PANELHEX_TO_ID[entity_hex] = StaticWitnessLocations.get_id(entity_hex) + + +StaticWitnessLocations() diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 68a4ac7fc231..18aa76d95ae9 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -1,5 +1,10 @@ from dataclasses import dataclass -from Options import Toggle, DefaultOnToggle, Range, Choice, PerGameCommonOptions + +from schema import Schema, And, Optional + +from Options import Toggle, DefaultOnToggle, Range, Choice, PerGameCommonOptions, OptionDict + +from worlds.witness.static_logic import WeightedItemDefinition, ItemCategory, StaticWitnessLogic class DisableNonRandomizedPuzzles(Toggle): @@ -172,6 +177,24 @@ class TrapPercentage(Range): default = 20 +class TrapWeights(OptionDict): + """Specify the weights determining how many copies of each trap item will be in your itempool. + If you don't want a specific type of trap, you can set the weight for it to 0 (Do not delete the entry outright!). + If you set all trap weights to 0, you will get no traps, bypassing the "Trap Percentage" option.""" + + display_name = "Trap Weights" + schema = Schema({ + trap_name: And(int, lambda n: n >= 0) + for trap_name, item_definition in StaticWitnessLogic.all_items.items() + if isinstance(item_definition, WeightedItemDefinition) and item_definition.category is ItemCategory.TRAP + }) + default = { + trap_name: item_definition.weight + for trap_name, item_definition in StaticWitnessLogic.all_items.items() + if isinstance(item_definition, WeightedItemDefinition) and item_definition.category is ItemCategory.TRAP + } + + class PuzzleSkipAmount(Range): """Adds this number of Puzzle Skips into the pool, if there is room. Puzzle Skips let you skip one panel. Works on most panels in the game - The only big exception is The Challenge.""" @@ -237,6 +260,7 @@ class TheWitnessOptions(PerGameCommonOptions): early_caves: EarlyCaves elevators_come_to_you: ElevatorsComeToYou trap_percentage: TrapPercentage + trap_weights: TrapWeights puzzle_skip_amount: PuzzleSkipAmount hint_amount: HintAmount area_hint_percentage: AreaHintPercentage diff --git a/worlds/witness/static_logic.py b/worlds/witness/static_logic.py index 5a3e8b1b580e..3efab4915e69 100644 --- a/worlds/witness/static_logic.py +++ b/worlds/witness/static_logic.py @@ -295,3 +295,6 @@ def __init__(self): self.EP_TO_OBELISK_SIDE.update(self.sigma_normal.EP_TO_OBELISK_SIDE) self.ENTITY_ID_TO_NAME.update(self.sigma_normal.ENTITY_ID_TO_NAME) + + +StaticWitnessLogic() diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index d30bef144464..b4e382e097d2 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -4,20 +4,22 @@ import settings import threading import typing -from typing import Any, Dict, List, Set, Tuple, Optional, cast +from typing import Any, Dict, List, Set, Tuple, Optional import os import logging from BaseClasses import ItemClassification, LocationProgressType, \ MultiWorld, Item, CollectionState, Entrance, Tutorial + +from .gen_data import GenData from .logic import cs_to_zz_locs from .region import ZillionLocation, ZillionRegion from .options import ZillionOptions, validate -from .id_maps import item_name_to_id as _item_name_to_id, \ +from .id_maps import ZillionSlotInfo, get_slot_info, item_name_to_id as _item_name_to_id, \ loc_name_to_id as _loc_name_to_id, make_id_to_others, \ zz_reg_name_to_reg_name, base_id from .item import ZillionItem -from .patch import ZillionDeltaPatch, get_base_rom_path +from .patch import ZillionPatch from zilliandomizer.randomizer import Randomizer as ZzRandomizer from zilliandomizer.system import System @@ -25,7 +27,7 @@ from zilliandomizer.logic_components.locations import Location as ZzLocation, Req from zilliandomizer.options import Chars -from ..AutoWorld import World, WebWorld +from worlds.AutoWorld import World, WebWorld class ZillionSettings(settings.Group): @@ -33,8 +35,8 @@ class RomFile(settings.UserFilePath): """File name of the Zillion US rom""" description = "Zillion US ROM File" copy_to = "Zillion (UE) [!].sms" - assert ZillionDeltaPatch.hash - md5s = [ZillionDeltaPatch.hash] + assert ZillionPatch.hash + md5s = [ZillionPatch.hash] class RomStart(str): """ @@ -134,14 +136,6 @@ def _make_item_maps(self, start_char: Chars) -> None: _id_to_name, _id_to_zz_id, id_to_zz_item = make_id_to_others(start_char) self.id_to_zz_item = id_to_zz_item - @classmethod - def stage_assert_generate(cls, multiworld: MultiWorld) -> None: - """Checks that a game is capable of generating, usually checks for some base file like a ROM. - Not run for unittests since they don't produce output""" - rom_file = get_base_rom_path() - if not os.path.exists(rom_file): - raise FileNotFoundError(rom_file) - def generate_early(self) -> None: if not hasattr(self.multiworld, "zillion_logic_cache"): setattr(self.multiworld, "zillion_logic_cache", {}) @@ -311,7 +305,9 @@ def stage_generate_basic(multiworld: MultiWorld, *args: Any) -> None: if sc != to_stay: group_players.remove(p) assert "world" in group - cast(ZillionWorld, group["world"])._make_item_maps(to_stay) + group_world = group["world"] + assert isinstance(group_world, ZillionWorld) + group_world._make_item_maps(to_stay) def post_fill(self) -> None: """Optional Method that is called after regular fill. Can be used to do adjustments before output generation. @@ -319,27 +315,28 @@ def post_fill(self) -> None: self.zz_system.post_fill() - def finalize_item_locations(self) -> None: + def finalize_item_locations(self) -> GenData: """ sync zilliandomizer item locations with AP item locations + + return the data needed to generate output """ - rom_dir_name = os.path.dirname(get_base_rom_path()) - self.zz_system.make_patcher(rom_dir_name) - assert self.zz_system.randomizer and self.zz_system.patcher, "generate_early hasn't been called" - zz_options = self.zz_system.randomizer.options + + assert self.zz_system.randomizer, "generate_early hasn't been called" # debug_zz_loc_ids: Dict[str, int] = {} empty = zz_items[4] multi_item = empty # a different patcher method differentiates empty from ap multi item multi_items: Dict[str, Tuple[str, str]] = {} # zz_loc_name to (item_name, player_name) - for loc in self.multiworld.get_locations(self.player): - z_loc = cast(ZillionLocation, loc) + for z_loc in self.multiworld.get_locations(self.player): + assert isinstance(z_loc, ZillionLocation) # debug_zz_loc_ids[z_loc.zz_loc.name] = id(z_loc.zz_loc) if z_loc.item is None: self.logger.warn("generate_output location has no item - is that ok?") z_loc.zz_loc.item = empty elif z_loc.item.player == self.player: - z_item = cast(ZillionItem, z_loc.item) + z_item = z_loc.item + assert isinstance(z_item, ZillionItem) z_loc.zz_loc.item = z_item.zz_item else: # another player's item # print(f"put multi item in {z_loc.zz_loc.name}") @@ -368,47 +365,32 @@ def finalize_item_locations(self) -> None: f"in world {self.player} didn't get an item" ) - zz_patcher = self.zz_system.patcher - - zz_patcher.write_locations(self.zz_system.randomizer.regions, - zz_options.start_char, - self.zz_system.randomizer.loc_name_2_pretty) - self.slot_data_ready.set() - rm = self.zz_system.resource_managers - assert rm, "missing resource_managers from generate_early" - zz_patcher.all_fixes_and_options(zz_options, rm) - zz_patcher.set_external_item_interface(zz_options.start_char, zz_options.max_level) - zz_patcher.set_multiworld_items(multi_items) game_id = self.multiworld.player_name[self.player].encode() + b'\x00' + self.multiworld.seed_name[-6:].encode() - zz_patcher.set_rom_to_ram_data(game_id) - def generate_output(self, output_directory: str) -> None: - """This method gets called from a threadpool, do not use world.random here. - If you need any last-second randomization, use MultiWorld.per_slot_randoms[slot] instead.""" - self.finalize_item_locations() + return GenData(multi_items, self.zz_system.get_game(), game_id) - assert self.zz_system.patcher, "didn't get patcher from finalize_item_locations" - # original_rom_bytes = self.zz_patcher.rom - patched_rom_bytes = self.zz_system.patcher.get_patched_bytes() + def generate_output(self, output_directory: str) -> None: + """This method gets called from a threadpool, do not use multiworld.random here. + If you need any last-second randomization, use self.random instead.""" + try: + gen_data = self.finalize_item_locations() + except BaseException: + raise + finally: + self.slot_data_ready.set() out_file_base = self.multiworld.get_out_file_name_base(self.player) - filename = os.path.join( - output_directory, - f'{out_file_base}{ZillionDeltaPatch.result_file_ending}' - ) - with open(filename, "wb") as binary_file: - binary_file.write(patched_rom_bytes) - patch = ZillionDeltaPatch( - os.path.splitext(filename)[0] + ZillionDeltaPatch.patch_file_ending, - player=self.player, - player_name=self.multiworld.player_name[self.player], - patched_path=filename - ) + patch_file_name = os.path.join(output_directory, f"{out_file_base}{ZillionPatch.patch_file_ending}") + patch = ZillionPatch(patch_file_name, + player=self.player, + player_name=self.multiworld.player_name[self.player], + gen_data_str=gen_data.to_json()) patch.write() - os.remove(filename) - def fill_slot_data(self) -> Dict[str, Any]: # json of WebHostLib.models.Slot + self.logger.debug(f"Zillion player {self.player} finished generate_output") + + def fill_slot_data(self) -> ZillionSlotInfo: # json of WebHostLib.models.Slot """Fill in the `slot_data` field in the `Connected` network package. This is a way the generator can give custom data to the client. The client will receive this as JSON in the `Connected` response.""" @@ -418,25 +400,10 @@ def fill_slot_data(self) -> Dict[str, Any]: # json of WebHostLib.models.Slot # TODO: tell client which canisters are keywords # so it can open and get those when restoring doors - assert self.zz_system.randomizer, "didn't get randomizer from generate_early" - - rescues: Dict[str, Any] = {} self.slot_data_ready.wait() - zz_patcher = self.zz_system.patcher - assert zz_patcher, "didn't get patcher from generate_output" - for i in (0, 1): - if i in zz_patcher.rescue_locations: - ri = zz_patcher.rescue_locations[i] - rescues[str(i)] = { - "start_char": ri.start_char, - "room_code": ri.room_code, - "mask": ri.mask - } - return { - "start_char": self.zz_system.randomizer.options.start_char, - "rescues": rescues, - "loc_mem_to_id": zz_patcher.loc_memory_to_loc_id - } + assert self.zz_system.randomizer, "didn't get randomizer from generate_early" + game = self.zz_system.get_game() + return get_slot_info(game.regions, game.char_order[0], game.loc_name_2_pretty) # def modify_multidata(self, multidata: Dict[str, Any]) -> None: # """For deeper modification of server multidata.""" diff --git a/worlds/zillion/client.py b/worlds/zillion/client.py index b10507aaf885..5c2e11453036 100644 --- a/worlds/zillion/client.py +++ b/worlds/zillion/client.py @@ -1,5 +1,7 @@ import asyncio import base64 +import io +import pkgutil import platform from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, cast @@ -10,14 +12,13 @@ import colorama -from zilliandomizer.zri.memory import Memory +from zilliandomizer.zri.memory import Memory, RescueInfo from zilliandomizer.zri import events from zilliandomizer.utils.loc_name_maps import id_to_loc from zilliandomizer.options import Chars -from zilliandomizer.patch import RescueInfo from .id_maps import loc_name_to_id, make_id_to_others -from .config import base_id, zillion_map +from .config import base_id class ZillionCommandProcessor(ClientCommandProcessor): @@ -138,7 +139,9 @@ def run_gui(self) -> None: from kvui import GameManager from kivy.core.text import Label as CoreLabel from kivy.graphics import Ellipse, Color, Rectangle + from kivy.graphics.texture import Texture from kivy.uix.layout import Layout + from kivy.uix.image import CoreImage from kivy.uix.widget import Widget class ZillionManager(GameManager): @@ -150,12 +153,21 @@ class ZillionManager(GameManager): class MapPanel(Widget): MAP_WIDTH: ClassVar[int] = 281 - _number_textures: List[Any] = [] + map_background: CoreImage + _number_textures: List[Texture] = [] rooms: List[List[int]] = [] def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + FILE_NAME = "empty-zillion-map-row-col-labels-281.png" + image_file_data = pkgutil.get_data(__name__, FILE_NAME) + if not image_file_data: + raise FileNotFoundError(f"{__name__=} {FILE_NAME=}") + data = io.BytesIO(image_file_data) + self.map_background = CoreImage(data, ext="png") + assert self.map_background.texture.size[0] == ZillionManager.MapPanel.MAP_WIDTH + self.rooms = [[0 for _ in range(8)] for _ in range(16)] self._make_numbers() @@ -176,10 +188,9 @@ def update_map(self, *args: Any) -> None: with self.canvas: Color(1, 1, 1, 1) - Rectangle(source=zillion_map, + Rectangle(texture=self.map_background.texture, pos=self.pos, - size=(ZillionManager.MapPanel.MAP_WIDTH, - int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image + size=self.map_background.texture.size) for y in range(16): for x in range(8): num = self.rooms[15 - y][x] @@ -194,7 +205,7 @@ def update_map(self, *args: Any) -> None: def build(self) -> Layout: container = super().build() - self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0) + self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=ZillionManager.MapPanel.MAP_WIDTH) self.main_area_container.add_widget(self.map_widget) return container diff --git a/worlds/zillion/config.py b/worlds/zillion/config.py index ca02f9a99f41..e08c4f4278ed 100644 --- a/worlds/zillion/config.py +++ b/worlds/zillion/config.py @@ -1,4 +1 @@ -import os - base_id = 8675309 -zillion_map = os.path.join(os.path.dirname(__file__), "empty-zillion-map-row-col-labels-281.png") diff --git a/worlds/zillion/gen_data.py b/worlds/zillion/gen_data.py new file mode 100644 index 000000000000..aa24ff8961b3 --- /dev/null +++ b/worlds/zillion/gen_data.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +import json +from typing import Dict, Tuple + +from zilliandomizer.game import Game as ZzGame + + +@dataclass +class GenData: + """ data passed from generation to patcher """ + + multi_items: Dict[str, Tuple[str, str]] + """ zz_loc_name to (item_name, player_name) """ + zz_game: ZzGame + game_id: bytes + """ the byte string used to detect the rom """ + + def to_json(self) -> str: + """ serialized data from generation needed to patch rom """ + jsonable = { + "multi_items": self.multi_items, + "zz_game": self.zz_game.to_jsonable(), + "game_id": list(self.game_id) + } + return json.dumps(jsonable) + + @staticmethod + def from_json(gen_data_str: str) -> "GenData": + """ the reverse of `to_json` """ + from_json = json.loads(gen_data_str) + return GenData( + from_json["multi_items"], + ZzGame.from_jsonable(from_json["zz_game"]), + bytes(from_json["game_id"]) + ) diff --git a/worlds/zillion/id_maps.py b/worlds/zillion/id_maps.py index bc9caeeece2e..32d71fc79b30 100644 --- a/worlds/zillion/id_maps.py +++ b/worlds/zillion/id_maps.py @@ -1,10 +1,22 @@ -from typing import Dict, Tuple -from zilliandomizer.logic_components.items import Item as ZzItem, \ - item_name_to_id as zz_item_name_to_zz_id, items as zz_items, \ - item_name_to_item as zz_item_name_to_zz_item +from collections import defaultdict +from typing import Dict, Iterable, Mapping, Tuple, TypedDict + +from zilliandomizer.logic_components.items import ( + Item as ZzItem, + KEYWORD, + NORMAL, + RESCUE, + item_name_to_id as zz_item_name_to_zz_id, + items as zz_items, + item_name_to_item as zz_item_name_to_zz_item, +) +from zilliandomizer.logic_components.regions import RegionData +from zilliandomizer.low_resources.item_rooms import item_room_codes from zilliandomizer.options import Chars from zilliandomizer.utils.loc_name_maps import loc_to_id as pretty_loc_name_to_id -from zilliandomizer.utils import parse_reg_name +from zilliandomizer.utils import parse_loc_name, parse_reg_name +from zilliandomizer.zri.memory import RescueInfo + from .config import base_id as base_id item_name_to_id = { @@ -91,3 +103,56 @@ def zz_reg_name_to_reg_name(zz_reg_name: str) -> str: end = zz_reg_name[5:] return f"{make_room_name(row, col)} {end.upper()}" return zz_reg_name + + +class ClientRescue(TypedDict): + start_char: Chars + room_code: int + mask: int + + +class ZillionSlotInfo(TypedDict): + start_char: Chars + rescues: Dict[str, ClientRescue] + loc_mem_to_id: Dict[int, int] + """ memory location of canister to Archipelago location id number """ + + +def get_slot_info(regions: Iterable[RegionData], + start_char: Chars, + loc_name_to_pretty: Mapping[str, str]) -> ZillionSlotInfo: + items_placed_in_map_index: Dict[int, int] = defaultdict(int) + rescue_locations: Dict[int, RescueInfo] = {} + loc_memory_to_loc_id: Dict[int, int] = {} + for region in regions: + for loc in region.locations: + assert loc.item, ("There should be an item placed in every location before " + f"writing slot info. {loc.name} is missing item.") + if loc.item.code in {KEYWORD, NORMAL, RESCUE}: + row, col, _y, _x = parse_loc_name(loc.name) + map_index = row * 8 + col + item_no = items_placed_in_map_index[map_index] + room_code = item_room_codes[map_index] + + r = room_code + m = 1 << item_no + if loc.item.code == RESCUE: + rescue_locations[loc.item.id] = RescueInfo(start_char, r, m) + loc_memory = (r << 7) | m + loc_memory_to_loc_id[loc_memory] = pretty_loc_name_to_id[loc_name_to_pretty[loc.name]] + items_placed_in_map_index[map_index] += 1 + + rescues: Dict[str, ClientRescue] = {} + for i in (0, 1): + if i in rescue_locations: + ri = rescue_locations[i] + rescues[str(i)] = { + "start_char": ri.start_char, + "room_code": ri.room_code, + "mask": ri.mask + } + return { + "start_char": start_char, + "rescues": rescues, + "loc_mem_to_id": loc_memory_to_loc_id + } diff --git a/worlds/zillion/patch.py b/worlds/zillion/patch.py index 148caac9fb7b..dcbb85bcfc89 100644 --- a/worlds/zillion/patch.py +++ b/worlds/zillion/patch.py @@ -1,22 +1,53 @@ -from typing import BinaryIO, Optional, cast -import Utils -from worlds.Files import APDeltaPatch import os +from typing import Any, BinaryIO, Optional, cast +import zipfile + +from typing_extensions import override + +import Utils +from worlds.Files import APPatch + +from zilliandomizer.patch import Patcher + +from .gen_data import GenData USHASH = 'd4bf9e7bcf9a48da53785d2ae7bc4270' -class ZillionDeltaPatch(APDeltaPatch): +class ZillionPatch(APPatch): hash = USHASH game = "Zillion" patch_file_ending = ".apzl" result_file_ending = ".sms" + gen_data_str: str + """ JSON encoded """ + + def __init__(self, *args: Any, gen_data_str: str = "", **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.gen_data_str = gen_data_str + @classmethod def get_source_data(cls) -> bytes: with open(get_base_rom_path(), "rb") as stream: return read_rom(stream) + @override + def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None: + super().write_contents(opened_zipfile) + opened_zipfile.writestr("gen_data.json", + self.gen_data_str, + compress_type=zipfile.ZIP_DEFLATED) + + @override + def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None: + super().read_contents(opened_zipfile) + self.gen_data_str = opened_zipfile.read("gen_data.json").decode() + + def patch(self, target: str) -> None: + self.read() + write_rom_from_gen_data(self.gen_data_str, target) + def get_base_rom_path(file_name: Optional[str] = None) -> str: options = Utils.get_options() @@ -32,3 +63,21 @@ def read_rom(stream: BinaryIO) -> bytes: data = stream.read() # I'm not aware of any sms header. return data + + +def write_rom_from_gen_data(gen_data_str: str, output_rom_file_name: str) -> None: + """ take the output of `GenData.to_json`, and create rom from it """ + gen_data = GenData.from_json(gen_data_str) + + base_rom_path = get_base_rom_path() + zz_patcher = Patcher(base_rom_path) + + zz_patcher.write_locations(gen_data.zz_game.regions, gen_data.zz_game.char_order[0]) + zz_patcher.all_fixes_and_options(gen_data.zz_game) + zz_patcher.set_external_item_interface(gen_data.zz_game.char_order[0], gen_data.zz_game.options.max_level) + zz_patcher.set_multiworld_items(gen_data.multi_items) + zz_patcher.set_rom_to_ram_data(gen_data.game_id) + + patched_rom_bytes = zz_patcher.get_patched_bytes() + with open(output_rom_file_name, "wb") as binary_file: + binary_file.write(patched_rom_bytes) diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt index c8944925acac..3a784846a891 100644 --- a/worlds/zillion/requirements.txt +++ b/worlds/zillion/requirements.txt @@ -1,2 +1,2 @@ -zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@ae00a4b186be897c7cfaf429a0e0ff83c4ecf28c#0.6.0 +zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@b36a23b5a138c78732ac8efb5b5ca8b0be07dcff#0.7.0 typing-extensions>=4.7, <5